Skip to main content

mini_apm/models/
user.rs

1use crate::DbPool;
2use argon2::{
3    Argon2,
4    password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
5};
6use chrono::{Duration, Utc};
7use rand::Rng;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct User {
12    pub id: i64,
13    pub username: String,
14    #[serde(skip_serializing)]
15    pub password_hash: Option<String>,
16    pub is_admin: bool,
17    pub must_change_password: bool,
18    #[serde(skip_serializing)]
19    pub invite_token: Option<String>,
20    pub invite_expires_at: Option<String>,
21    pub created_at: String,
22    pub last_login_at: Option<String>,
23}
24
25#[derive(Debug, Clone)]
26pub struct Session {
27    pub id: i64,
28    pub token: String,
29    pub user_id: i64,
30    pub created_at: String,
31    pub expires_at: String,
32}
33
34/// Hash a password using Argon2
35pub fn hash_password(password: &str) -> anyhow::Result<String> {
36    let salt = SaltString::generate(&mut OsRng);
37    let argon2 = Argon2::default();
38    let hash = argon2
39        .hash_password(password.as_bytes(), &salt)
40        .map_err(|e| anyhow::anyhow!("Failed to hash password: {}", e))?;
41    Ok(hash.to_string())
42}
43
44/// Verify a password against a hash
45pub fn verify_password(password: &str, hash: &str) -> bool {
46    let parsed_hash = match PasswordHash::new(hash) {
47        Ok(h) => h,
48        Err(_) => return false,
49    };
50    Argon2::default()
51        .verify_password(password.as_bytes(), &parsed_hash)
52        .is_ok()
53}
54
55/// Generate a random session token
56fn generate_token() -> String {
57    let mut rng = rand::thread_rng();
58    let bytes: [u8; 32] = rng.r#gen();
59    hex::encode(bytes)
60}
61
62/// Create the default admin user if no users exist
63pub fn ensure_default_admin(pool: &DbPool) -> anyhow::Result<()> {
64    let conn = pool.get()?;
65
66    let count: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?;
67
68    if count == 0 {
69        let password_hash = hash_password("admin")?;
70        let now = Utc::now().to_rfc3339();
71
72        conn.execute(
73            "INSERT INTO users (username, password_hash, is_admin, must_change_password, created_at) VALUES (?1, ?2, 1, 1, ?3)",
74            ("admin", &password_hash, &now),
75        )?;
76
77        tracing::info!("Created default admin user (admin/admin) - please change password!");
78    }
79
80    Ok(())
81}
82
83/// Authenticate a user and return them if successful
84pub fn authenticate(pool: &DbPool, username: &str, password: &str) -> anyhow::Result<Option<User>> {
85    let conn = pool.get()?;
86
87    let user: Option<User> = conn
88        .query_row(
89            "SELECT id, username, password_hash, is_admin, must_change_password, invite_token, invite_expires_at, created_at, last_login_at FROM users WHERE username = ?1",
90            [username],
91            |row| {
92                Ok(User {
93                    id: row.get(0)?,
94                    username: row.get(1)?,
95                    password_hash: row.get(2)?,
96                    is_admin: row.get::<_, i64>(3)? == 1,
97                    must_change_password: row.get::<_, i64>(4)? == 1,
98                    invite_token: row.get(5)?,
99                    invite_expires_at: row.get(6)?,
100                    created_at: row.get(7)?,
101                    last_login_at: row.get(8)?,
102                })
103            },
104        )
105        .ok();
106
107    match user {
108        Some(ref u)
109            if u.password_hash
110                .as_ref()
111                .is_some_and(|h| verify_password(password, h)) =>
112        {
113            // Update last login time
114            let now = Utc::now().to_rfc3339();
115            let _ = conn.execute(
116                "UPDATE users SET last_login_at = ?1 WHERE id = ?2",
117                (&now, u.id),
118            );
119            Ok(user)
120        }
121        _ => Ok(None),
122    }
123}
124
125/// Create a new session for a user
126pub fn create_session(pool: &DbPool, user_id: i64) -> anyhow::Result<String> {
127    let conn = pool.get()?;
128    let token = generate_token();
129    let now = Utc::now();
130    let expires = now + Duration::days(7);
131
132    conn.execute(
133        "INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?1, ?2, ?3, ?4)",
134        (&token, user_id, now.to_rfc3339(), expires.to_rfc3339()),
135    )?;
136
137    Ok(token)
138}
139
140/// Get user from session token
141pub fn get_user_from_session(pool: &DbPool, token: &str) -> anyhow::Result<Option<User>> {
142    let conn = pool.get()?;
143    let now = Utc::now().to_rfc3339();
144
145    let user: Option<User> = conn
146        .query_row(
147            r#"
148            SELECT u.id, u.username, u.password_hash, u.is_admin, u.must_change_password, u.invite_token, u.invite_expires_at, u.created_at, u.last_login_at
149            FROM users u
150            JOIN sessions s ON s.user_id = u.id
151            WHERE s.token = ?1 AND s.expires_at > ?2
152            "#,
153            [token, &now],
154            |row| {
155                Ok(User {
156                    id: row.get(0)?,
157                    username: row.get(1)?,
158                    password_hash: row.get(2)?,
159                    is_admin: row.get::<_, i64>(3)? == 1,
160                    must_change_password: row.get::<_, i64>(4)? == 1,
161                    invite_token: row.get(5)?,
162                    invite_expires_at: row.get(6)?,
163                    created_at: row.get(7)?,
164                    last_login_at: row.get(8)?,
165                })
166            },
167        )
168        .ok();
169
170    Ok(user)
171}
172
173/// Delete a session (logout)
174pub fn delete_session(pool: &DbPool, token: &str) -> anyhow::Result<()> {
175    let conn = pool.get()?;
176    conn.execute("DELETE FROM sessions WHERE token = ?1", [token])?;
177    Ok(())
178}
179
180/// Delete expired sessions (cleanup)
181pub fn delete_expired_sessions(pool: &DbPool) -> anyhow::Result<usize> {
182    let conn = pool.get()?;
183    let now = Utc::now().to_rfc3339();
184    let deleted = conn.execute("DELETE FROM sessions WHERE expires_at < ?1", [&now])?;
185    Ok(deleted)
186}
187
188/// List all users (admin only)
189pub fn list_all(pool: &DbPool) -> anyhow::Result<Vec<User>> {
190    let conn = pool.get()?;
191    let mut stmt = conn.prepare(
192        r#"SELECT id, username, password_hash, is_admin, must_change_password, invite_token, invite_expires_at,
193                  strftime('%Y-%m-%d %H:%M', created_at),
194                  CASE WHEN last_login_at IS NOT NULL THEN strftime('%Y-%m-%d %H:%M', last_login_at) ELSE NULL END
195           FROM users ORDER BY username"#,
196    )?;
197
198    let users = stmt
199        .query_map([], |row| {
200            Ok(User {
201                id: row.get(0)?,
202                username: row.get(1)?,
203                password_hash: row.get(2)?,
204                is_admin: row.get::<_, i64>(3)? == 1,
205                must_change_password: row.get::<_, i64>(4)? == 1,
206                invite_token: row.get(5)?,
207                invite_expires_at: row.get(6)?,
208                created_at: row.get(7)?,
209                last_login_at: row.get(8)?,
210            })
211        })?
212        .collect::<Result<Vec<_>, _>>()?;
213
214    Ok(users)
215}
216
217/// Create a new user (admin only)
218pub fn create(
219    pool: &DbPool,
220    username: &str,
221    password: &str,
222    is_admin: bool,
223) -> anyhow::Result<i64> {
224    let conn = pool.get()?;
225    let password_hash = hash_password(password)?;
226    let now = Utc::now().to_rfc3339();
227
228    conn.execute(
229        "INSERT INTO users (username, password_hash, is_admin, must_change_password, created_at) VALUES (?1, ?2, ?3, 0, ?4)",
230        (username, &password_hash, if is_admin { 1 } else { 0 }, &now),
231    )?;
232
233    Ok(conn.last_insert_rowid())
234}
235
236/// Delete a user (admin only, cannot delete self)
237pub fn delete(pool: &DbPool, user_id: i64) -> anyhow::Result<()> {
238    let conn = pool.get()?;
239    conn.execute("DELETE FROM users WHERE id = ?1", [user_id])?;
240    Ok(())
241}
242
243/// Change password
244pub fn change_password(pool: &DbPool, user_id: i64, new_password: &str) -> anyhow::Result<()> {
245    let conn = pool.get()?;
246    let password_hash = hash_password(new_password)?;
247
248    conn.execute(
249        "UPDATE users SET password_hash = ?1, must_change_password = 0 WHERE id = ?2",
250        (&password_hash, user_id),
251    )?;
252
253    Ok(())
254}
255
256/// Find user by ID
257pub fn find(pool: &DbPool, id: i64) -> anyhow::Result<Option<User>> {
258    let conn = pool.get()?;
259
260    let user: Option<User> = conn
261        .query_row(
262            "SELECT id, username, password_hash, is_admin, must_change_password, invite_token, invite_expires_at, created_at, last_login_at FROM users WHERE id = ?1",
263            [id],
264            |row| {
265                Ok(User {
266                    id: row.get(0)?,
267                    username: row.get(1)?,
268                    password_hash: row.get(2)?,
269                    is_admin: row.get::<_, i64>(3)? == 1,
270                    must_change_password: row.get::<_, i64>(4)? == 1,
271                    invite_token: row.get(5)?,
272                    invite_expires_at: row.get(6)?,
273                    created_at: row.get(7)?,
274                    last_login_at: row.get(8)?,
275                })
276            },
277        )
278        .ok();
279
280    Ok(user)
281}
282
283/// Generate an invite token (12 bytes = 24 hex chars, short but secure)
284pub fn generate_invite_token() -> String {
285    let mut rng = rand::thread_rng();
286    let bytes: [u8; 12] = rng.r#gen();
287    hex::encode(bytes)
288}
289
290/// Create a new user with an invite token (no password yet)
291pub fn create_with_invite(pool: &DbPool, username: &str, is_admin: bool) -> anyhow::Result<String> {
292    let conn = pool.get()?;
293    let invite_token = generate_invite_token();
294    let now = Utc::now();
295    let expires = now + Duration::days(7);
296
297    conn.execute(
298        "INSERT INTO users (username, is_admin, invite_token, invite_expires_at, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
299        (username, if is_admin { 1 } else { 0 }, &invite_token, expires.to_rfc3339(), now.to_rfc3339()),
300    )?;
301
302    Ok(invite_token)
303}
304
305/// Find user by invite token
306pub fn find_by_invite_token(pool: &DbPool, token: &str) -> anyhow::Result<Option<User>> {
307    let conn = pool.get()?;
308    let now = Utc::now().to_rfc3339();
309
310    let user: Option<User> = conn
311        .query_row(
312            "SELECT id, username, password_hash, is_admin, must_change_password, invite_token, invite_expires_at, created_at, last_login_at FROM users WHERE invite_token = ?1 AND invite_expires_at > ?2",
313            [token, &now],
314            |row| {
315                Ok(User {
316                    id: row.get(0)?,
317                    username: row.get(1)?,
318                    password_hash: row.get(2)?,
319                    is_admin: row.get::<_, i64>(3)? == 1,
320                    must_change_password: row.get::<_, i64>(4)? == 1,
321                    invite_token: row.get(5)?,
322                    invite_expires_at: row.get(6)?,
323                    created_at: row.get(7)?,
324                    last_login_at: row.get(8)?,
325                })
326            },
327        )
328        .ok();
329
330    Ok(user)
331}
332
333/// Accept an invite - set password and clear invite token
334pub fn accept_invite(pool: &DbPool, user_id: i64, password: &str) -> anyhow::Result<()> {
335    let conn = pool.get()?;
336    let password_hash = hash_password(password)?;
337
338    conn.execute(
339        "UPDATE users SET password_hash = ?1, invite_token = NULL, invite_expires_at = NULL WHERE id = ?2",
340        (&password_hash, user_id),
341    )?;
342
343    Ok(())
344}