| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
use argon2::{ |
| 7 |
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, |
| 8 |
Argon2, |
| 9 |
}; |
| 10 |
use async_trait::async_trait; |
| 11 |
use sqlx::SqlitePool; |
| 12 |
use goingson_core::{CoreError, Result, User, UserId, UserRepository}; |
| 13 |
|
| 14 |
use crate::utils::{format_datetime_now, parse_datetime, parse_uuid}; |
| 15 |
|
| 16 |
#[derive(Debug, Clone, sqlx::FromRow)] |
| 17 |
struct UserRow { |
| 18 |
pub id: String, |
| 19 |
pub email: String, |
| 20 |
pub password_hash: String, |
| 21 |
pub display_name: String, |
| 22 |
pub created_at: String, |
| 23 |
pub last_login_at: Option<String>, |
| 24 |
} |
| 25 |
|
| 26 |
impl TryFrom<UserRow> for User { |
| 27 |
type Error = CoreError; |
| 28 |
|
| 29 |
fn try_from(row: UserRow) -> std::result::Result<Self, Self::Error> { |
| 30 |
Ok(User { |
| 31 |
id: parse_uuid(&row.id)?.into(), |
| 32 |
email: row.email, |
| 33 |
password_hash: row.password_hash, |
| 34 |
display_name: row.display_name, |
| 35 |
created_at: parse_datetime(&row.created_at)?, |
| 36 |
last_login_at: row.last_login_at.as_ref().map(|s| parse_datetime(s)).transpose()?, |
| 37 |
}) |
| 38 |
} |
| 39 |
} |
| 40 |
|
| 41 |
|
| 42 |
|
| 43 |
|
| 44 |
|
| 45 |
pub struct SqliteUserRepository { |
| 46 |
pool: SqlitePool, |
| 47 |
} |
| 48 |
|
| 49 |
impl SqliteUserRepository { |
| 50 |
|
| 51 |
#[tracing::instrument(skip_all)] |
| 52 |
pub fn new(pool: SqlitePool) -> Self { |
| 53 |
Self { pool } |
| 54 |
} |
| 55 |
|
| 56 |
|
| 57 |
fn hash_password(password: &str) -> Result<String> { |
| 58 |
let salt = SaltString::generate(&mut OsRng); |
| 59 |
let argon2 = Argon2::default(); |
| 60 |
let hash = argon2.hash_password(password.as_bytes(), &salt).map_err(|e| CoreError::internal(format!("Password hash error: {}", e)))?; |
| 61 |
Ok(hash.to_string()) |
| 62 |
} |
| 63 |
|
| 64 |
|
| 65 |
fn verify_password(password: &str, hash: &str) -> bool { |
| 66 |
let parsed_hash = match PasswordHash::new(hash) { |
| 67 |
Ok(h) => h, |
| 68 |
Err(_) => return false, |
| 69 |
}; |
| 70 |
Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok() |
| 71 |
} |
| 72 |
} |
| 73 |
|
| 74 |
#[async_trait] |
| 75 |
impl UserRepository for SqliteUserRepository { |
| 76 |
#[tracing::instrument(skip_all)] |
| 77 |
async fn create(&self, email: &str, password: &str, display_name: &str) -> Result<User> { |
| 78 |
let id = UserId::new(); |
| 79 |
let password_hash = Self::hash_password(password)?; |
| 80 |
let now = format_datetime_now(); |
| 81 |
|
| 82 |
sqlx::query("INSERT INTO users (id, email, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)") |
| 83 |
.bind(id.to_string()) |
| 84 |
.bind(email.to_lowercase()) |
| 85 |
.bind(&password_hash) |
| 86 |
.bind(display_name) |
| 87 |
.bind(&now) |
| 88 |
.execute(&self.pool) |
| 89 |
.await |
| 90 |
.map_err(CoreError::database)?; |
| 91 |
|
| 92 |
self.find_by_email(email).await?.ok_or_else(|| CoreError::internal("Failed to retrieve created user")) |
| 93 |
} |
| 94 |
|
| 95 |
#[tracing::instrument(skip_all)] |
| 96 |
async fn find_by_email(&self, email: &str) -> Result<Option<User>> { |
| 97 |
let row = sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE email = ?") |
| 98 |
.bind(email.to_lowercase()) |
| 99 |
.fetch_optional(&self.pool) |
| 100 |
.await |
| 101 |
.map_err(CoreError::database)?; |
| 102 |
|
| 103 |
row.map(User::try_from).transpose() |
| 104 |
} |
| 105 |
|
| 106 |
#[tracing::instrument(skip_all)] |
| 107 |
async fn authenticate(&self, email: &str, password: &str) -> Result<Option<User>> { |
| 108 |
let user = self.find_by_email(email).await?; |
| 109 |
|
| 110 |
match user { |
| 111 |
Some(u) if Self::verify_password(password, &u.password_hash) => { |
| 112 |
self.update_last_login(u.id).await?; |
| 113 |
Ok(Some(u)) |
| 114 |
} |
| 115 |
_ => Ok(None), |
| 116 |
} |
| 117 |
} |
| 118 |
|
| 119 |
#[tracing::instrument(skip_all)] |
| 120 |
async fn update_last_login(&self, user_id: UserId) -> Result<()> { |
| 121 |
let now = format_datetime_now(); |
| 122 |
sqlx::query("UPDATE users SET last_login_at = ? WHERE id = ?") |
| 123 |
.bind(&now) |
| 124 |
.bind(user_id.to_string()) |
| 125 |
.execute(&self.pool) |
| 126 |
.await |
| 127 |
.map_err(CoreError::database)?; |
| 128 |
|
| 129 |
Ok(()) |
| 130 |
} |
| 131 |
} |
| 132 |
|