1use anyhow::{Context, Result};
2use argon2::{
3 password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
4 Argon2,
5};
6use chrono::{DateTime, Utc};
7use rusqlite::{params, Connection};
8use uuid::Uuid;
9
10use rand::Rng;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum UserRole {
14 Admin,
15 Researcher,
16 Viewer,
17}
18
19impl UserRole {
20 pub fn as_str(&self) -> &str {
21 match self {
22 UserRole::Admin => "Admin",
23 UserRole::Researcher => "Researcher",
24 UserRole::Viewer => "Viewer",
25 }
26 }
27
28 #[allow(clippy::should_implement_trait)]
29 pub fn from_str(s: &str) -> Result<Self> {
30 match s {
31 "Admin" => Ok(UserRole::Admin),
32 "Researcher" => Ok(UserRole::Researcher),
33 "Viewer" => Ok(UserRole::Viewer),
34 _ => anyhow::bail!("Invalid role: {}", s),
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
40pub struct User {
41 pub id: String,
42 pub username: String,
43 pub email: String,
44 pub password_hash: String,
45 pub role: UserRole,
46 pub created_at: DateTime<Utc>,
47}
48
49#[derive(Debug, Clone)]
50pub struct Session {
51 pub user_id: String,
52 pub token: String,
53 pub expires_at: DateTime<Utc>,
54}
55
56pub struct AuthService<'a> {
57 conn: &'a Connection,
58}
59
60impl<'a> AuthService<'a> {
61 pub fn new(conn: &'a Connection) -> Self {
62 AuthService { conn }
63 }
64
65 pub fn init_auth_schema(&self) -> Result<()> {
66 self.conn
67 .execute_batch(
68 "CREATE TABLE IF NOT EXISTS users (
69 id TEXT PRIMARY KEY,
70 username TEXT NOT NULL UNIQUE,
71 email TEXT NOT NULL UNIQUE,
72 password_hash TEXT NOT NULL,
73 role TEXT NOT NULL DEFAULT 'Viewer',
74 created_at TEXT NOT NULL
75 );
76
77 CREATE TABLE IF NOT EXISTS sessions (
78 user_id TEXT NOT NULL,
79 token TEXT PRIMARY KEY,
80 expires_at TEXT NOT NULL,
81 FOREIGN KEY (user_id) REFERENCES users(id)
82 );
83
84 CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
85 CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);",
86 )
87 .context("Failed to create auth schema")?;
88 Ok(())
89 }
90
91 pub fn register(
92 &self,
93 username: &str,
94 email: &str,
95 password: &str,
96 role: UserRole,
97 ) -> Result<User> {
98 let salt = SaltString::generate(&mut OsRng);
99 let argon2 = Argon2::default();
100 let password_hash = argon2
101 .hash_password(password.as_bytes(), &salt)
102 .map_err(|e| anyhow::anyhow!("Failed to hash password: {}", e))?
103 .to_string();
104
105 let id = Uuid::new_v4().to_string();
106 let created_at = Utc::now();
107
108 self.conn
109 .execute(
110 "INSERT INTO users (id, username, email, password_hash, role, created_at)
111 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
112 params![
113 id,
114 username,
115 email,
116 password_hash,
117 role.as_str(),
118 created_at.to_rfc3339(),
119 ],
120 )
121 .context("Failed to insert user")?;
122
123 Ok(User {
124 id,
125 username: username.to_string(),
126 email: email.to_string(),
127 password_hash,
128 role,
129 created_at,
130 })
131 }
132
133 pub fn login(&self, username: &str, password: &str) -> Result<Session> {
134 let user = self
135 .get_user_by_username(username)?
136 .ok_or_else(|| anyhow::anyhow!("User not found"))?;
137
138 let parsed_hash = PasswordHash::new(&user.password_hash)
139 .map_err(|e| anyhow::anyhow!("Failed to parse password hash: {}", e))?;
140
141 Argon2::default()
142 .verify_password(password.as_bytes(), &parsed_hash)
143 .map_err(|_| anyhow::anyhow!("Invalid password"))?;
144
145 let token: String = rand::thread_rng()
146 .sample_iter(&rand::distributions::Alphanumeric)
147 .take(64)
148 .map(char::from)
149 .collect();
150
151 let expires_at = Utc::now() + chrono::Duration::hours(24);
152
153 self.conn
154 .execute("DELETE FROM sessions WHERE user_id = ?1", params![user.id])
155 .context("Failed to clean old sessions")?;
156
157 self.conn
158 .execute(
159 "INSERT INTO sessions (user_id, token, expires_at)
160 VALUES (?1, ?2, ?3)",
161 params![user.id, token, expires_at.to_rfc3339()],
162 )
163 .context("Failed to create session")?;
164
165 Ok(Session {
166 user_id: user.id,
167 token,
168 expires_at,
169 })
170 }
171
172 pub fn validate_token(&self, token: &str) -> Result<Option<User>> {
173 let result = self.conn.query_row(
174 "SELECT u.id, u.username, u.email, u.password_hash, u.role, u.created_at
175 FROM users u
176 JOIN sessions s ON u.id = s.user_id
177 WHERE s.token = ?1 AND s.expires_at > datetime('now')",
178 params![token],
179 |row| {
180 Ok(User {
181 id: row.get(0)?,
182 username: row.get(1)?,
183 email: row.get(2)?,
184 password_hash: row.get(3)?,
185 role: UserRole::from_str(&row.get::<_, String>(4)?).unwrap_or(UserRole::Viewer),
186 created_at: {
187 let s: String = row.get(5)?;
188 DateTime::parse_from_rfc3339(&s)
189 .map(|dt| dt.with_timezone(&Utc))
190 .unwrap_or_else(|_| Utc::now())
191 },
192 })
193 },
194 );
195
196 match result {
197 Ok(user) => Ok(Some(user)),
198 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
199 Err(e) => Err(e.into()),
200 }
201 }
202
203 pub fn get_user(&self, user_id: &str) -> Result<Option<User>> {
204 let result = self.conn.query_row(
205 "SELECT id, username, email, password_hash, role, created_at
206 FROM users WHERE id = ?1",
207 params![user_id],
208 |row| {
209 Ok(User {
210 id: row.get(0)?,
211 username: row.get(1)?,
212 email: row.get(2)?,
213 password_hash: row.get(3)?,
214 role: UserRole::from_str(&row.get::<_, String>(4)?).unwrap_or(UserRole::Viewer),
215 created_at: {
216 let s: String = row.get(5)?;
217 DateTime::parse_from_rfc3339(&s)
218 .map(|dt| dt.with_timezone(&Utc))
219 .unwrap_or_else(|_| Utc::now())
220 },
221 })
222 },
223 );
224
225 match result {
226 Ok(user) => Ok(Some(user)),
227 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
228 Err(e) => Err(e.into()),
229 }
230 }
231
232 fn get_user_by_username(&self, username: &str) -> Result<Option<User>> {
233 let result = self.conn.query_row(
234 "SELECT id, username, email, password_hash, role, created_at
235 FROM users WHERE username = ?1",
236 params![username],
237 |row| {
238 Ok(User {
239 id: row.get(0)?,
240 username: row.get(1)?,
241 email: row.get(2)?,
242 password_hash: row.get(3)?,
243 role: UserRole::from_str(&row.get::<_, String>(4)?).unwrap_or(UserRole::Viewer),
244 created_at: {
245 let s: String = row.get(5)?;
246 DateTime::parse_from_rfc3339(&s)
247 .map(|dt| dt.with_timezone(&Utc))
248 .unwrap_or_else(|_| Utc::now())
249 },
250 })
251 },
252 );
253
254 match result {
255 Ok(user) => Ok(Some(user)),
256 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
257 Err(e) => Err(e.into()),
258 }
259 }
260
261 pub fn logout(&self, token: &str) -> Result<()> {
262 self.conn
263 .execute("DELETE FROM sessions WHERE token = ?1", params![token])
264 .context("Failed to delete session")?;
265 Ok(())
266 }
267
268 pub fn list_users(&self) -> Result<Vec<User>> {
269 let mut stmt = self
270 .conn
271 .prepare("SELECT id, username, email, password_hash, role, created_at FROM users")?;
272
273 let users = stmt
274 .query_map([], |row| {
275 Ok(User {
276 id: row.get(0)?,
277 username: row.get(1)?,
278 email: row.get(2)?,
279 password_hash: row.get(3)?,
280 role: UserRole::from_str(&row.get::<_, String>(4)?).unwrap_or(UserRole::Viewer),
281 created_at: {
282 let s: String = row.get(5)?;
283 DateTime::parse_from_rfc3339(&s)
284 .map(|dt| dt.with_timezone(&Utc))
285 .unwrap_or_else(|_| Utc::now())
286 },
287 })
288 })?
289 .collect::<Result<Vec<_>, _>>()?;
290
291 Ok(users)
292 }
293}