use argon2::password_hash::SaltString;
use argon2::password_hash::rand_core::OsRng;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use crate::db::DbPool;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: i64,
pub uuid: String,
pub login: String,
pub name: String,
pub email: String,
pub apikey: String,
pub is_active: bool,
pub is_admin: bool,
pub created_at: String,
pub last_seen: Option<String>,
}
pub fn hash_password(password: &str) -> Result<String, String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| format!("Password hashing failed: {e}"))
}
pub fn verify_password(password: &str, hash: &str) -> Result<bool, String> {
let parsed_hash = PasswordHash::new(hash).map_err(|e| format!("Invalid password hash: {e}"))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
#[allow(clippy::too_many_arguments)]
pub fn create_user(
conn: &Connection,
login: &str,
name: &str,
email: &str,
password: &str,
is_admin: bool,
) -> Result<User, String> {
let uuid = uuid::Uuid::new_v4().to_string();
let apikey = uuid::Uuid::new_v4().to_string();
let pwdhash = hash_password(password)?;
conn.execute(
"INSERT INTO users (uuid, login, name, email, pwdhash, apikey, is_admin) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
uuid,
login,
name,
email,
pwdhash,
apikey,
i32::from(is_admin)
],
)
.map_err(|e| format!("Failed to create user: {e}"))?;
let id = conn.last_insert_rowid();
Ok(User {
id,
uuid,
login: login.to_owned(),
name: name.to_owned(),
email: email.to_owned(),
apikey,
is_active: true,
is_admin,
created_at: chrono::Utc::now().to_rfc3339(),
last_seen: None,
})
}
pub fn find_by_login(pool: &DbPool, login: &str) -> Result<Option<User>, String> {
pool.with_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT id, uuid, login, name, email, apikey, is_active, is_admin, \
created_at, last_seen FROM users WHERE login = ?1",
)?;
let user = stmt
.query_row(params![login], |row| {
Ok(User {
id: row.get("id")?,
uuid: row.get("uuid")?,
login: row.get("login")?,
name: row.get("name")?,
email: row.get("email")?,
apikey: row.get("apikey")?,
is_active: row.get::<_, i32>("is_active")? != 0,
is_admin: row.get::<_, i32>("is_admin")? != 0,
created_at: row.get("created_at")?,
last_seen: row.get("last_seen")?,
})
})
.optional()?;
Ok(user)
})
.map_err(|e| format!("Database query failed: {e}"))
}
use rusqlite::OptionalExtension;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify_password() {
let test_input = "secure_p@ssw0rd_2026";
let hash = hash_password(test_input).expect("hashing failed");
assert!(verify_password(test_input, &hash).expect("verify failed"));
assert!(!verify_password("wrong_input", &hash).expect("verify failed"));
}
#[test]
fn test_create_user() {
let pool = DbPool::open_in_memory().expect("DB open failed");
pool.with_conn(|conn| {
let user = create_user(
conn,
"testuser",
"Test User",
"test@example.com",
"password123",
false,
)
.expect("user creation failed");
assert_eq!(user.login, "testuser");
assert!(!user.is_admin);
assert!(user.is_active);
Ok(())
})
.expect("with_conn failed");
}
#[test]
fn test_find_by_login() {
let pool = DbPool::open_in_memory().expect("DB open failed");
pool.with_conn(|conn| {
create_user(conn, "admin", "Admin", "admin@test.com", "admin123", true)
.expect("user creation failed");
Ok(())
})
.expect("with_conn failed");
let user = find_by_login(&pool, "admin")
.expect("query failed")
.expect("user not found");
assert_eq!(user.login, "admin");
assert!(user.is_admin);
let missing = find_by_login(&pool, "nonexistent").expect("query failed");
assert!(missing.is_none());
}
}