//! SQLite implementation of the UserRepository. //! //! Manages user accounts with secure password hashing via Argon2. //! Supports user creation, authentication, and last login tracking. use argon2::{ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2, }; use async_trait::async_trait; use sqlx::SqlitePool; use goingson_core::{CoreError, Result, User, UserId, UserRepository}; use crate::utils::{format_datetime_now, parse_datetime, parse_uuid}; #[derive(Debug, Clone, sqlx::FromRow)] struct UserRow { pub id: String, pub email: String, pub password_hash: String, pub display_name: String, pub created_at: String, pub last_login_at: Option, } impl TryFrom for User { type Error = CoreError; fn try_from(row: UserRow) -> std::result::Result { Ok(User { id: parse_uuid(&row.id)?.into(), email: row.email, password_hash: row.password_hash, display_name: row.display_name, created_at: parse_datetime(&row.created_at)?, last_login_at: row.last_login_at.as_ref().map(|s| parse_datetime(s)).transpose()?, }) } } /// SQLite-backed implementation of [`UserRepository`]. /// /// Handles user account management with secure Argon2 password hashing. /// All passwords are salted and hashed before storage. pub struct SqliteUserRepository { pool: SqlitePool, } impl SqliteUserRepository { /// Creates a new repository instance with the given connection pool. #[tracing::instrument(skip_all)] pub fn new(pool: SqlitePool) -> Self { Self { pool } } /// Hashes a plaintext password using Argon2 with a random salt. fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); let hash = argon2.hash_password(password.as_bytes(), &salt).map_err(|e| CoreError::internal(format!("Password hash error: {}", e)))?; Ok(hash.to_string()) } /// Verifies a plaintext password against a stored Argon2 hash. fn verify_password(password: &str, hash: &str) -> bool { let parsed_hash = match PasswordHash::new(hash) { Ok(h) => h, Err(_) => return false, }; Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok() } } #[async_trait] impl UserRepository for SqliteUserRepository { #[tracing::instrument(skip_all)] async fn create(&self, email: &str, password: &str, display_name: &str) -> Result { let id = UserId::new(); let password_hash = Self::hash_password(password)?; let now = format_datetime_now(); sqlx::query("INSERT INTO users (id, email, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)") .bind(id.to_string()) .bind(email.to_lowercase()) .bind(&password_hash) .bind(display_name) .bind(&now) .execute(&self.pool) .await .map_err(CoreError::database)?; self.find_by_email(email).await?.ok_or_else(|| CoreError::internal("Failed to retrieve created user")) } #[tracing::instrument(skip_all)] async fn find_by_email(&self, email: &str) -> Result> { let row = sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE email = ?") .bind(email.to_lowercase()) .fetch_optional(&self.pool) .await .map_err(CoreError::database)?; row.map(User::try_from).transpose() } #[tracing::instrument(skip_all)] async fn authenticate(&self, email: &str, password: &str) -> Result> { let user = self.find_by_email(email).await?; match user { Some(u) if Self::verify_password(password, &u.password_hash) => { self.update_last_login(u.id).await?; Ok(Some(u)) } _ => Ok(None), } } #[tracing::instrument(skip_all)] async fn update_last_login(&self, user_id: UserId) -> Result<()> { let now = format_datetime_now(); sqlx::query("UPDATE users SET last_login_at = ? WHERE id = ?") .bind(&now) .bind(user_id.to_string()) .execute(&self.pool) .await .map_err(CoreError::database)?; Ok(()) } }