Skip to main content

max / goingson

4.4 KB · 132 lines History Blame Raw
1 //! SQLite implementation of the UserRepository.
2 //!
3 //! Manages user accounts with secure password hashing via Argon2.
4 //! Supports user creation, authentication, and last login tracking.
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 /// SQLite-backed implementation of [`UserRepository`].
42 ///
43 /// Handles user account management with secure Argon2 password hashing.
44 /// All passwords are salted and hashed before storage.
45 pub struct SqliteUserRepository {
46 pool: SqlitePool,
47 }
48
49 impl SqliteUserRepository {
50 /// Creates a new repository instance with the given connection pool.
51 #[tracing::instrument(skip_all)]
52 pub fn new(pool: SqlitePool) -> Self {
53 Self { pool }
54 }
55
56 /// Hashes a plaintext password using Argon2 with a random salt.
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 /// Verifies a plaintext password against a stored Argon2 hash.
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