diff options
| author | UMTS at Teleco <crt@teleco.ch> | 2025-12-13 02:48:13 +0100 |
|---|---|---|
| committer | UMTS at Teleco <crt@teleco.ch> | 2025-12-13 02:48:13 +0100 |
| commit | e52b8e1c2e110d0feb74feb7905c2ff064b51d55 (patch) | |
| tree | 3090814e422250e07e72cf1c83241ffd95cf20f7 /src/auth | |
Diffstat (limited to 'src/auth')
| -rw-r--r-- | src/auth/mod.rs | 6 | ||||
| -rw-r--r-- | src/auth/password.rs | 56 | ||||
| -rw-r--r-- | src/auth/pin.rs | 66 | ||||
| -rw-r--r-- | src/auth/session.rs | 129 | ||||
| -rw-r--r-- | src/auth/token.rs | 99 |
5 files changed, 356 insertions, 0 deletions
diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..65cba10 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,6 @@ +pub mod password; +pub mod pin; +pub mod session; +pub mod token; + +pub use session::SessionManager; diff --git a/src/auth/password.rs b/src/auth/password.rs new file mode 100644 index 0000000..580ad1f --- /dev/null +++ b/src/auth/password.rs @@ -0,0 +1,56 @@ +// Password authentication module +use crate::models::{Role, User}; +use anyhow::{Context, Result}; +use bcrypt::verify; +use sqlx::MySqlPool; + +pub async fn authenticate_password( + pool: &MySqlPool, + username: &str, + password: &str, +) -> Result<Option<(User, Role)>> { + // Fetch user from database + let user: Option<User> = sqlx::query_as::<_, User>( + r#" + SELECT id, name, username, password, pin_code, login_string, role_id, + email, phone, notes, active, last_login_date, created_date, + password_reset_token, password_reset_expiry + FROM users + WHERE username = ? AND active = TRUE + "#, + ) + .bind(username) + .fetch_optional(pool) + .await + .context("Failed to fetch user from database")?; + + if let Some(user) = user { + // Verify password + let password_valid = + verify(password, &user.password).context("Failed to verify password")?; + + if password_valid { + // Fetch user's role + let role: Role = sqlx::query_as::<_, Role>( + "SELECT id, name, power, created_at FROM roles WHERE id = ?", + ) + .bind(user.role_id) + .fetch_one(pool) + .await + .context("Failed to fetch user role")?; + + // Update last login date + sqlx::query("UPDATE users SET last_login_date = NOW() WHERE id = ?") + .bind(user.id) + .execute(pool) + .await + .context("Failed to update last login date")?; + + Ok(Some((user, role))) + } else { + Ok(None) + } + } else { + Ok(None) + } +} diff --git a/src/auth/pin.rs b/src/auth/pin.rs new file mode 100644 index 0000000..4d1993c --- /dev/null +++ b/src/auth/pin.rs @@ -0,0 +1,66 @@ +// PIN authentication module +use crate::config::SecurityConfig; +use crate::models::{Role, User}; +use anyhow::{Context, Result}; +use sqlx::MySqlPool; + +pub async fn authenticate_pin( + pool: &MySqlPool, + username: &str, + pin: &str, + security_config: &SecurityConfig, +) -> Result<Option<(User, Role)>> { + // Fetch user from database + let user: Option<User> = sqlx::query_as::<_, User>( + r#" + SELECT id, name, username, password, pin_code, login_string, role_id, + email, phone, notes, active, last_login_date, created_date, + password_reset_token, password_reset_expiry + FROM users + WHERE username = ? AND active = TRUE AND pin_code IS NOT NULL + "#, + ) + .bind(username) + .fetch_optional(pool) + .await + .context("Failed to fetch user from database")?; + + if let Some(user) = user { + // Check if user has a PIN set + if let Some(user_pin) = &user.pin_code { + // Verify PIN - either bcrypt hash or plaintext depending on config + let pin_valid = if security_config.hash_pins { + bcrypt::verify(pin, user_pin).unwrap_or(false) + } else { + user_pin == pin + }; + + if pin_valid { + // Fetch user's role + let role: Role = sqlx::query_as::<_, Role>( + "SELECT id, name, power, created_at FROM roles WHERE id = ?", + ) + .bind(user.role_id) + .fetch_one(pool) + .await + .context("Failed to fetch user role")?; + + // Update last login date + sqlx::query("UPDATE users SET last_login_date = NOW() WHERE id = ?") + .bind(user.id) + .execute(pool) + .await + .context("Failed to update last login date")?; + + Ok(Some((user, role))) + } else { + Ok(None) + } + } else { + // User doesn't have a PIN set + Ok(None) + } + } else { + Ok(None) + } +} diff --git a/src/auth/session.rs b/src/auth/session.rs new file mode 100644 index 0000000..277cef2 --- /dev/null +++ b/src/auth/session.rs @@ -0,0 +1,129 @@ +// Session management for SeckelAPI +use crate::config::Config; +use crate::models::Session; +use chrono::{Duration, Utc}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use uuid::Uuid; + +#[derive(Clone)] +pub struct SessionManager { + sessions: Arc<RwLock<HashMap<String, Session>>>, + config: Arc<Config>, +} + +impl SessionManager { + pub fn new(config: Arc<Config>) -> Self { + Self { + sessions: Arc::new(RwLock::new(HashMap::new())), + config, + } + } + + fn get_timeout_for_power(&self, power: i32) -> u64 { + self.config.get_session_timeout(power) + } + + pub fn create_session( + &self, + user_id: i32, + username: String, + role_id: i32, + role_name: String, + power: i32, + ) -> String { + let token = Uuid::new_v4().to_string(); + let now = Utc::now(); + + let session = Session { + user_id, + username, + role_id, + role_name, + power, + created_at: now, + last_accessed: now, + }; + + if let Ok(mut sessions) = self.sessions.write() { + // Check concurrent session limit for this user + let max_sessions = self.config.get_max_concurrent_sessions(power); + let user_sessions: Vec<(String, chrono::DateTime<Utc>)> = sessions + .iter() + .filter(|(_, s)| s.user_id == user_id) + .map(|(token, s)| (token.clone(), s.created_at)) + .collect(); + + // If at limit, remove the oldest session + if user_sessions.len() >= max_sessions as usize { + if let Some(oldest_token) = user_sessions + .iter() + .min_by_key(|(_, created)| created) + .map(|(token, _)| token.clone()) + { + sessions.remove(&oldest_token); + } + } + + sessions.insert(token.clone(), session); + } + + token + } + + pub fn get_session(&self, token: &str) -> Option<Session> { + let mut session_to_update = None; + + { + if let Ok(sessions) = self.sessions.read() { + if let Some(session) = sessions.get(token) { + let now = Utc::now(); + let timeout_for_user = self.get_timeout_for_power(session.power); + let timeout_duration = Duration::minutes(timeout_for_user as i64); + + // Check if session has expired + if now - session.last_accessed > timeout_duration { + return None; // Session expired, will be cleaned up later + } else { + session_to_update = Some(session.clone()); + } + } + } + } + + if let Some(mut session) = session_to_update { + // Update last accessed time only if refresh_on_activity is enabled + if self.config.security.refresh_session_on_activity { + session.last_accessed = Utc::now(); + + if let Ok(mut sessions) = self.sessions.write() { + sessions.insert(token.to_string(), session.clone()); + } + } + + Some(session) + } else { + None + } + } + + pub fn remove_session(&self, token: &str) -> bool { + if let Ok(mut sessions) = self.sessions.write() { + sessions.remove(token).is_some() + } else { + false + } + } + + pub fn cleanup_expired_sessions(&self) { + let now = Utc::now(); + + if let Ok(mut sessions) = self.sessions.write() { + sessions.retain(|_, session| { + let timeout_for_user = self.get_timeout_for_power(session.power); + let timeout_duration = Duration::minutes(timeout_for_user as i64); + now - session.last_accessed <= timeout_duration + }); + } + } +} diff --git a/src/auth/token.rs b/src/auth/token.rs new file mode 100644 index 0000000..17f75e4 --- /dev/null +++ b/src/auth/token.rs @@ -0,0 +1,99 @@ +// Token/RFID authentication module +use crate::config::SecurityConfig; +use crate::models::{Role, User}; +use anyhow::{Context, Result}; +use sqlx::MySqlPool; + +pub async fn authenticate_token( + pool: &MySqlPool, + login_string: &str, + security_config: &SecurityConfig, +) -> Result<Option<(User, Role)>> { + // If hashing is enabled, we can't use WHERE login_string = ? directly + // Need to fetch all users and verify hashes + if security_config.hash_tokens { + // Fetch all active users with login_string set + let users: Vec<User> = sqlx::query_as::<_, User>( + r#" + SELECT id, name, username, password, pin_code, login_string, role_id, + email, phone, notes, active, last_login_date, created_date, + password_reset_token, password_reset_expiry + FROM users + WHERE login_string IS NOT NULL AND active = TRUE + "#, + ) + .fetch_all(pool) + .await + .context("Failed to fetch users from database")?; + + // Find matching user by verifying bcrypt hash + for user in users { + if let Some(ref stored_hash) = user.login_string { + if bcrypt::verify(login_string, stored_hash).unwrap_or(false) { + // Found matching user + return authenticate_user_by_id(pool, user.id).await; + } + } + } + Ok(None) + } else { + // Plaintext comparison - direct database query + let user: Option<User> = sqlx::query_as::<_, User>( + r#" + SELECT id, name, username, password, pin_code, login_string, role_id, + email, phone, notes, active, last_login_date, created_date, + password_reset_token, password_reset_expiry + FROM users + WHERE login_string = ? AND active = TRUE + "#, + ) + .bind(login_string) + .fetch_optional(pool) + .await + .context("Failed to fetch user from database")?; + + if let Some(user) = user { + authenticate_user_by_id(pool, user.id).await + } else { + Ok(None) + } + } +} + +async fn authenticate_user_by_id(pool: &MySqlPool, user_id: i32) -> Result<Option<(User, Role)>> { + // Fetch user + let user: User = sqlx::query_as::<_, User>( + r#" + SELECT id, name, username, password, pin_code, login_string, role_id, + email, phone, notes, active, last_login_date, created_date, + password_reset_token, password_reset_expiry + FROM users + WHERE id = ? + "#, + ) + .bind(user_id) + .fetch_one(pool) + .await + .context("Failed to fetch user")?; + + if user.active { + // Fetch user's role + let role: Role = + sqlx::query_as::<_, Role>("SELECT id, name, power, created_at FROM roles WHERE id = ?") + .bind(user.role_id) + .fetch_one(pool) + .await + .context("Failed to fetch user role")?; + + // Update last login date + sqlx::query("UPDATE users SET last_login_date = NOW() WHERE id = ?") + .bind(user.id) + .execute(pool) + .await + .context("Failed to update last login date")?; + + Ok(Some((user, role))) + } else { + Ok(None) + } +} |
