Skip to main content

howler_core/
auth.rs

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}