Skip to main content

csaf_models/
user.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne
3
4//! User model with Argon2id password hashing.
5
6use argon2::password_hash::SaltString;
7use argon2::password_hash::rand_core::OsRng;
8use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
9use rusqlite::{Connection, params};
10use serde::{Deserialize, Serialize};
11
12use crate::db::DbPool;
13
14/// A user account.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct User {
17    /// Database ID.
18    pub id: i64,
19    /// Unique UUID.
20    pub uuid: String,
21    /// Login name.
22    pub login: String,
23    /// Display name.
24    pub name: String,
25    /// Email address.
26    pub email: String,
27    /// API key.
28    pub apikey: String,
29    /// Whether the account is active.
30    pub is_active: bool,
31    /// Whether the account has admin privileges.
32    pub is_admin: bool,
33    /// Account creation timestamp.
34    pub created_at: String,
35    /// Last seen timestamp.
36    pub last_seen: Option<String>,
37}
38
39/// Hash a password using Argon2id (RFC 9106).
40///
41/// # Errors
42///
43/// Returns an error string if hashing fails.
44pub fn hash_password(password: &str) -> Result<String, String> {
45    let salt = SaltString::generate(&mut OsRng);
46    let argon2 = Argon2::default();
47    argon2
48        .hash_password(password.as_bytes(), &salt)
49        .map(|h| h.to_string())
50        .map_err(|e| format!("Password hashing failed: {e}"))
51}
52
53/// Verify a password against an Argon2id hash.
54///
55/// # Errors
56///
57/// Returns an error string if verification fails or the hash is invalid.
58pub fn verify_password(password: &str, hash: &str) -> Result<bool, String> {
59    let parsed_hash = PasswordHash::new(hash).map_err(|e| format!("Invalid password hash: {e}"))?;
60    Ok(Argon2::default()
61        .verify_password(password.as_bytes(), &parsed_hash)
62        .is_ok())
63}
64
65/// Create a new user in the database.
66///
67/// # Errors
68///
69/// Returns a database error if the insert fails.
70#[allow(clippy::too_many_arguments)]
71pub fn create_user(
72    conn: &Connection,
73    login: &str,
74    name: &str,
75    email: &str,
76    password: &str,
77    is_admin: bool,
78) -> Result<User, String> {
79    let uuid = uuid::Uuid::new_v4().to_string();
80    let apikey = uuid::Uuid::new_v4().to_string();
81    let pwdhash = hash_password(password)?;
82
83    conn.execute(
84        "INSERT INTO users (uuid, login, name, email, pwdhash, apikey, is_admin) \
85         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
86        params![
87            uuid,
88            login,
89            name,
90            email,
91            pwdhash,
92            apikey,
93            i32::from(is_admin)
94        ],
95    )
96    .map_err(|e| format!("Failed to create user: {e}"))?;
97
98    let id = conn.last_insert_rowid();
99
100    Ok(User {
101        id,
102        uuid,
103        login: login.to_owned(),
104        name: name.to_owned(),
105        email: email.to_owned(),
106        apikey,
107        is_active: true,
108        is_admin,
109        created_at: chrono::Utc::now().to_rfc3339(),
110        last_seen: None,
111    })
112}
113
114/// Find a user by login name.
115///
116/// # Errors
117///
118/// Returns a database error if the query fails.
119pub fn find_by_login(pool: &DbPool, login: &str) -> Result<Option<User>, String> {
120    pool.with_conn(|conn| {
121        let mut stmt = conn.prepare(
122            "SELECT id, uuid, login, name, email, apikey, is_active, is_admin, \
123             created_at, last_seen FROM users WHERE login = ?1",
124        )?;
125
126        let user = stmt
127            .query_row(params![login], |row| {
128                Ok(User {
129                    id: row.get("id")?,
130                    uuid: row.get("uuid")?,
131                    login: row.get("login")?,
132                    name: row.get("name")?,
133                    email: row.get("email")?,
134                    apikey: row.get("apikey")?,
135                    is_active: row.get::<_, i32>("is_active")? != 0,
136                    is_admin: row.get::<_, i32>("is_admin")? != 0,
137                    created_at: row.get("created_at")?,
138                    last_seen: row.get("last_seen")?,
139                })
140            })
141            .optional()?;
142
143        Ok(user)
144    })
145    .map_err(|e| format!("Database query failed: {e}"))
146}
147
148use rusqlite::OptionalExtension;
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_hash_and_verify_password() {
156        // Test fixture — not a real credential.
157        let test_input = "secure_p@ssw0rd_2026";
158        let hash = hash_password(test_input).expect("hashing failed");
159        assert!(verify_password(test_input, &hash).expect("verify failed"));
160        assert!(!verify_password("wrong_input", &hash).expect("verify failed"));
161    }
162
163    #[test]
164    fn test_create_user() {
165        let pool = DbPool::open_in_memory().expect("DB open failed");
166        pool.with_conn(|conn| {
167            let user = create_user(
168                conn,
169                "testuser",
170                "Test User",
171                "test@example.com",
172                "password123",
173                false,
174            )
175            .expect("user creation failed");
176            assert_eq!(user.login, "testuser");
177            assert!(!user.is_admin);
178            assert!(user.is_active);
179            Ok(())
180        })
181        .expect("with_conn failed");
182    }
183
184    #[test]
185    fn test_find_by_login() {
186        let pool = DbPool::open_in_memory().expect("DB open failed");
187        pool.with_conn(|conn| {
188            create_user(conn, "admin", "Admin", "admin@test.com", "admin123", true)
189                .expect("user creation failed");
190            Ok(())
191        })
192        .expect("with_conn failed");
193
194        let user = find_by_login(&pool, "admin")
195            .expect("query failed")
196            .expect("user not found");
197        assert_eq!(user.login, "admin");
198        assert!(user.is_admin);
199
200        let missing = find_by_login(&pool, "nonexistent").expect("query failed");
201        assert!(missing.is_none());
202    }
203}