1use argon2::{Argon2, PasswordHash, PasswordVerifier};
8use sqlx::types::Uuid;
9use std::fmt::Debug;
10use supp_macro::command;
11
12use super::{CmdError, CmdResult};
13use crate::config::ConfigError;
14use crate::db::get_connection;
15
16#[derive(Debug, Clone)]
20pub struct VerifiedUser {
21 pub user_id: Uuid,
22 pub ssh_enabled: bool,
23}
24
25command! {
36 VerifyUserPassword {
37 #[required]
38 email: String,
39 #[required]
40 password: String,
41 } => {
42 match verify_user_password(&email, &password).await? {
43 Some(v) => Ok(Some(CmdResult::Uuid(v.user_id))),
44 None => Ok(Some(CmdResult::Bool(false))),
45 }
46 }
47}
48
49pub async fn verify_user_password(
59 email: &str,
60 password: &str,
61) -> Result<Option<VerifiedUser>, ConfigError> {
62 let mut conn = get_connection().await.map_err(|err| {
63 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
64 ConfigError::DB
65 })?;
66 let Some(row) = sqlx::query_file!("sql/select/users/by_email.sql", email.to_ascii_lowercase())
67 .fetch_optional(&mut *conn)
68 .await?
69 else {
70 return Ok(None);
71 };
72 let Ok(parsed_hash) = PasswordHash::new(&row.password_hash) else {
73 return Ok(None);
74 };
75 let is_valid = Argon2::default()
76 .verify_password(password.as_bytes(), &parsed_hash)
77 .is_ok();
78 if !is_valid {
79 return Ok(None);
80 }
81 Ok(Some(VerifiedUser {
82 user_id: row.id,
83 ssh_enabled: row.ssh_enabled,
84 }))
85}
86
87#[cfg(test)]
88mod command_tests {
89 use super::*;
90 use crate::db::DB_POOL;
91 use argon2::{Argon2, PasswordHasher};
92 use sqlx::PgPool;
93 use supp_macro::local_db_sqlx_test;
94 use tokio::sync::OnceCell;
95
96 static CONTEXT: OnceCell<()> = OnceCell::const_new();
97
98 async fn setup() {
99 CONTEXT
100 .get_or_init(|| async {
101 #[cfg(feature = "testlog")]
102 let _ = env_logger::builder()
103 .is_test(true)
104 .filter_level(log::LevelFilter::Trace)
105 .try_init();
106 })
107 .await;
108 }
109
110 async fn insert_user_with_password(
111 id: Uuid,
112 email: &str,
113 plaintext: &str,
114 ) -> anyhow::Result<()> {
115 let hash = Argon2::default()
116 .hash_password(plaintext.as_bytes())
117 .unwrap()
118 .to_string();
119 let mut conn = get_connection().await.unwrap();
120 sqlx::query!(
121 "INSERT INTO users (
122 id, user_name, email, photo, verified,
123 user_password, user_role, db_name, created_at
124 ) VALUES (
125 $1, 'Test', $2, 'default.png', FALSE,
126 $3, 'user', 'db', NOW()
127 )",
128 id,
129 email,
130 hash,
131 )
132 .execute(&mut *conn)
133 .await?;
134 Ok(())
135 }
136
137 #[local_db_sqlx_test]
138 async fn verify_returns_user_on_correct_password(pool: PgPool) -> anyhow::Result<()> {
139 let uid = Uuid::new_v4();
140 insert_user_with_password(uid, "good@example.com", "hunter2").await?;
141 let v = verify_user_password("good@example.com", "hunter2")
142 .await?
143 .expect("match");
144 assert_eq!(v.user_id, uid);
145 assert!(!v.ssh_enabled);
146 }
147
148 #[local_db_sqlx_test]
149 async fn verify_returns_none_on_wrong_password(pool: PgPool) -> anyhow::Result<()> {
150 let uid = Uuid::new_v4();
151 insert_user_with_password(uid, "wrong@example.com", "hunter2").await?;
152 let v = verify_user_password("wrong@example.com", "nope").await?;
153 assert!(v.is_none());
154 }
155
156 #[local_db_sqlx_test]
157 async fn verify_returns_none_for_unknown_email(pool: PgPool) -> anyhow::Result<()> {
158 let _ = pool;
159 let v = verify_user_password("nobody@example.com", "whatever").await?;
160 assert!(v.is_none());
161 }
162
163 #[local_db_sqlx_test]
164 async fn verify_is_case_insensitive_on_email(pool: PgPool) -> anyhow::Result<()> {
165 let uid = Uuid::new_v4();
166 insert_user_with_password(uid, "mixed@example.com", "pw").await?;
167 let v = verify_user_password("MIXED@EXAMPLE.COM", "pw")
168 .await?
169 .expect("match");
170 assert_eq!(v.user_id, uid);
171 }
172}