Lines
90.2 %
Functions
24.24 %
Branches
100 %
//! User-centric commands that cross both the web and SSH login paths.
//!
//! Extracted from `web/src/handler.rs` so the SSH daemon reuses the
//! exact same password-verification code: same Argon2 parameters,
//! same "invalid email or password" failure mode.
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use sqlx::types::Uuid;
use std::fmt::Debug;
use supp_macro::command;
use super::{CmdError, CmdResult};
use crate::config::ConfigError;
use crate::db::get_connection;
/// What a successful `VerifyUserPassword` returns. The daemon gates
/// the entire password code path on `ssh_enabled = TRUE`; the web
/// handler ignores that flag since it owns its own login surface.
#[derive(Debug, Clone)]
pub struct VerifiedUser {
pub user_id: Uuid,
pub ssh_enabled: bool,
}
// Verify an email + plaintext password against the `users` table.
// Returns `Some(CmdResult::Uuid(user_id))` on match,
// `Some(CmdResult::Bool(false))` on any failure (wrong email, wrong
// password, malformed hash in the DB). Callers get a single failure
// mode so they can't leak which of the two was wrong via timing or
// distinct error messages.
//
// The SSH daemon gates its password path on `ssh_enabled` and calls
// [`verify_user_password`] directly so it can see the full
// `VerifiedUser` shape instead of just the user id.
command! {
VerifyUserPassword {
#[required]
email: String,
password: String,
} => {
match verify_user_password(&email, &password).await? {
Some(v) => Ok(Some(CmdResult::Uuid(v.user_id))),
None => Ok(Some(CmdResult::Bool(false))),
/// Shared verification helper returning the richer `VerifiedUser`
/// shape. The SSH daemon uses this directly so it can gate the
/// password path on `ssh_enabled`.
///
/// # Errors
/// Returns `Err` only on infrastructure failure (DB unavailable).
/// Mismatched credentials resolve to `Ok(None)` — the single failure
/// mode the caller should pattern-match on.
pub async fn verify_user_password(
email: &str,
password: &str,
) -> Result<Option<VerifiedUser>, ConfigError> {
let mut conn = get_connection().await.map_err(|err| {
log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
ConfigError::DB
})?;
let Some(row) = sqlx::query_file!("sql/select/users/by_email.sql", email.to_ascii_lowercase())
.fetch_optional(&mut *conn)
.await?
else {
return Ok(None);
};
let Ok(parsed_hash) = PasswordHash::new(&row.password_hash) else {
let is_valid = Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok();
if !is_valid {
Ok(Some(VerifiedUser {
user_id: row.id,
ssh_enabled: row.ssh_enabled,
}))
#[cfg(test)]
mod command_tests {
use super::*;
use crate::db::DB_POOL;
use argon2::{Argon2, PasswordHasher};
use sqlx::PgPool;
use supp_macro::local_db_sqlx_test;
use tokio::sync::OnceCell;
static CONTEXT: OnceCell<()> = OnceCell::const_new();
async fn setup() {
CONTEXT
.get_or_init(|| async {
#[cfg(feature = "testlog")]
let _ = env_logger::builder()
.is_test(true)
.filter_level(log::LevelFilter::Trace)
.try_init();
})
.await;
async fn insert_user_with_password(
id: Uuid,
plaintext: &str,
) -> anyhow::Result<()> {
let hash = Argon2::default()
.hash_password(plaintext.as_bytes())
.unwrap()
.to_string();
let mut conn = get_connection().await.unwrap();
sqlx::query!(
"INSERT INTO users (
id, user_name, email, photo, verified,
user_password, user_role, db_name, created_at
) VALUES (
$1, 'Test', $2, 'default.png', FALSE,
$3, 'user', 'db', NOW()
)",
id,
email,
hash,
)
.execute(&mut *conn)
.await?;
Ok(())
#[local_db_sqlx_test]
async fn verify_returns_user_on_correct_password(pool: PgPool) -> anyhow::Result<()> {
let uid = Uuid::new_v4();
insert_user_with_password(uid, "good@example.com", "hunter2").await?;
let v = verify_user_password("good@example.com", "hunter2")
.expect("match");
assert_eq!(v.user_id, uid);
assert!(!v.ssh_enabled);
async fn verify_returns_none_on_wrong_password(pool: PgPool) -> anyhow::Result<()> {
insert_user_with_password(uid, "wrong@example.com", "hunter2").await?;
let v = verify_user_password("wrong@example.com", "nope").await?;
assert!(v.is_none());
async fn verify_returns_none_for_unknown_email(pool: PgPool) -> anyhow::Result<()> {
let _ = pool;
let v = verify_user_password("nobody@example.com", "whatever").await?;
async fn verify_is_case_insensitive_on_email(pool: PgPool) -> anyhow::Result<()> {
insert_user_with_password(uid, "mixed@example.com", "pw").await?;
let v = verify_user_password("MIXED@EXAMPLE.COM", "pw")