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

            
7
use argon2::{Argon2, PasswordHash, PasswordVerifier};
8
use sqlx::types::Uuid;
9
use std::fmt::Debug;
10
use supp_macro::command;
11

            
12
use super::{CmdError, CmdResult};
13
use crate::config::ConfigError;
14
use 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)]
20
pub 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.
35
command! {
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.
58
4
pub async fn verify_user_password(
59
4
    email: &str,
60
4
    password: &str,
61
4
) -> Result<Option<VerifiedUser>, ConfigError> {
62
4
    let mut conn = get_connection().await.map_err(|err| {
63
        log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
64
        ConfigError::DB
65
    })?;
66
4
    let Some(row) = sqlx::query_file!("sql/select/users/by_email.sql", email.to_ascii_lowercase())
67
4
        .fetch_optional(&mut *conn)
68
4
        .await?
69
    else {
70
1
        return Ok(None);
71
    };
72
3
    let Ok(parsed_hash) = PasswordHash::new(&row.password_hash) else {
73
        return Ok(None);
74
    };
75
3
    let is_valid = Argon2::default()
76
3
        .verify_password(password.as_bytes(), &parsed_hash)
77
3
        .is_ok();
78
3
    if !is_valid {
79
1
        return Ok(None);
80
2
    }
81
2
    Ok(Some(VerifiedUser {
82
2
        user_id: row.id,
83
2
        ssh_enabled: row.ssh_enabled,
84
2
    }))
85
4
}
86

            
87
#[cfg(test)]
88
mod 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
4
    async fn setup() {
99
4
        CONTEXT
100
4
            .get_or_init(|| async {
101
                #[cfg(feature = "testlog")]
102
1
                let _ = env_logger::builder()
103
1
                    .is_test(true)
104
1
                    .filter_level(log::LevelFilter::Trace)
105
1
                    .try_init();
106
2
            })
107
4
            .await;
108
4
    }
109

            
110
3
    async fn insert_user_with_password(
111
3
        id: Uuid,
112
3
        email: &str,
113
3
        plaintext: &str,
114
3
    ) -> anyhow::Result<()> {
115
3
        let hash = Argon2::default()
116
3
            .hash_password(plaintext.as_bytes())
117
3
            .unwrap()
118
3
            .to_string();
119
3
        let mut conn = get_connection().await.unwrap();
120
3
        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
3
        .execute(&mut *conn)
133
3
        .await?;
134
3
        Ok(())
135
3
    }
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
}