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
34pub 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
44pub 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
55fn generate_token() -> String {
57 let mut rng = rand::thread_rng();
58 let bytes: [u8; 32] = rng.r#gen();
59 hex::encode(bytes)
60}
61
62pub 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
83pub 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 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
125pub 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
140pub 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
173pub 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
180pub 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
188pub 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
217pub 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
236pub 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
243pub 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
256pub 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
283pub 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
290pub 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
305pub 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
333pub 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}