Skip to main content

server/command/
user.rs

1//! User-centric commands that cross both the web and SSH login paths.
2//!
3//! Extracted from `web/src/handler.rs` so the SSH daemon reuses the
4//! exact same password-verification code: same Argon2 parameters,
5//! same "invalid email or password" failure mode.
6
7use 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/// What a successful `VerifyUserPassword` returns. The daemon gates
17/// the entire password code path on `ssh_enabled = TRUE`; the web
18/// handler ignores that flag since it owns its own login surface.
19#[derive(Debug, Clone)]
20pub struct VerifiedUser {
21    pub user_id: Uuid,
22    pub ssh_enabled: bool,
23}
24
25// Verify an email + plaintext password against the `users` table.
26// Returns `Some(CmdResult::Uuid(user_id))` on match,
27// `Some(CmdResult::Bool(false))` on any failure (wrong email, wrong
28// password, malformed hash in the DB). Callers get a single failure
29// mode so they can't leak which of the two was wrong via timing or
30// distinct error messages.
31//
32// The SSH daemon gates its password path on `ssh_enabled` and calls
33// [`verify_user_password`] directly so it can see the full
34// `VerifiedUser` shape instead of just the user id.
35command! {
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
49/// Shared verification helper returning the richer `VerifiedUser`
50/// shape. The SSH daemon uses this directly so it can gate the
51/// password path on `ssh_enabled`.
52///
53/// # Errors
54///
55/// Returns `Err` only on infrastructure failure (DB unavailable).
56/// Mismatched credentials resolve to `Ok(None)` — the single failure
57/// mode the caller should pattern-match on.
58pub 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}