1use crate::prelude::*;
2use crate::users::get_by_id;
3use argon2::password_hash::{SaltString, rand_core::OsRng};
4use argon2::{Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version};
5use headless_lms_utils::tmc::TmcClient;
6use secrecy::{ExposeSecret, SecretString};
7use std::sync::LazyLock;
8pub struct UserPassword {
9 pub user_id: Uuid,
10 pub password_hash: SecretString,
11 pub created_at: DateTime<Utc>,
12 pub updated_at: DateTime<Utc>,
13 pub deleted_at: Option<DateTime<Utc>>,
14}
15
16#[derive(sqlx::FromRow, Debug, Clone)]
17pub struct PasswordResetToken {
18 pub token: Uuid,
19 pub user_id: Uuid,
20 pub created_at: DateTime<Utc>,
21 pub updated_at: DateTime<Utc>,
22 pub expires_at: DateTime<Utc>,
23 pub used_at: Option<DateTime<Utc>>,
24 pub deleted_at: Option<DateTime<Utc>>,
25}
26
27pub async fn upsert_user_password(
28 conn: &mut PgConnection,
29 user_id: Uuid,
30 password_hash: &SecretString,
31) -> ModelResult<bool> {
32 let result = sqlx::query!(
33 r#"
34INSERT INTO user_passwords (user_id, password_hash)
35VALUES ($1, $2) ON CONFLICT (user_id) DO
36UPDATE
37SET password_hash = EXCLUDED.password_hash,
38 deleted_at = NULL
39 "#,
40 user_id,
41 password_hash.expose_secret()
42 )
43 .execute(conn)
44 .await?;
45
46 Ok(result.rows_affected() > 0)
47}
48
49pub fn hash_password(
50 password: &SecretString,
51) -> Result<SecretString, argon2::password_hash::Error> {
52 let salt = SaltString::generate(&mut OsRng);
53 let argon2 = Argon2::new(
54 Algorithm::Argon2id,
55 Version::V0x13,
56 Params::new(65536, 3, 4, None)?,
57 );
58
59 let password_hash = argon2.hash_password(password.expose_secret().as_bytes(), &salt)?;
60 Ok(SecretString::new(password_hash.to_string().into()))
61}
62
63pub async fn verify_user_password(
64 conn: &mut PgConnection,
65 user_id: Uuid,
66 password: &SecretString,
67) -> ModelResult<bool> {
68 let user_password = match sqlx::query!(
69 r#"
70SELECT password_hash
71FROM user_passwords
72WHERE user_id = $1
73 AND deleted_at IS NULL
74 "#,
75 user_id
76 )
77 .fetch_optional(conn)
78 .await?
79 {
80 Some(p) => p,
81 None => {
82 static DUMMY_HASH: LazyLock<String> = LazyLock::new(|| {
86 let salt = SaltString::generate(&mut OsRng);
87 Argon2::default()
88 .hash_password(b"dummy-password", &salt)
89 .expect("failed to create dummy hash")
90 .to_string()
91 });
92
93 let parsed = PasswordHash::new(&DUMMY_HASH).map_err(|e| {
94 ModelError::new(
95 ModelErrorType::Generic,
96 format!("Failed to parse DUMMY_HASH: {}", e),
97 Some(anyhow::anyhow!("Password hash error: {}", e)),
98 )
99 })?;
100 let _ = Argon2::default().verify_password(b"dummy-password", &parsed);
101 return Ok(false);
102 }
103 };
104
105 let parsed_hash = match PasswordHash::new(&user_password.password_hash) {
106 Ok(hash) => hash,
107 Err(_) => return Ok(false),
108 };
109
110 let is_valid = Argon2::default()
111 .verify_password(password.expose_secret().as_bytes(), &parsed_hash)
112 .is_ok();
113
114 Ok(is_valid)
115}
116
117pub async fn check_if_users_password_is_stored(
118 conn: &mut PgConnection,
119 user_id: Uuid,
120) -> ModelResult<bool> {
121 let result = sqlx::query!(
122 r#"
123SELECT *
124FROM user_passwords
125WHERE user_id = $1
126 AND deleted_at IS NULL
127 "#,
128 user_id
129 )
130 .fetch_optional(conn)
131 .await?;
132
133 Ok(result.is_some())
134}
135
136pub async fn insert_password_reset_token(
137 conn: &mut PgConnection,
138 user_id: Uuid,
139 token: Uuid,
140) -> ModelResult<Uuid> {
141 let mut tx = conn.begin().await?;
142
143 let _ = sqlx::query!(
145 r#"
146 UPDATE password_reset_tokens
147SET deleted_at = NOW()
148WHERE user_id = $1
149 AND deleted_at IS NULL
150 "#,
151 user_id
152 )
153 .execute(&mut *tx)
154 .await?;
155
156 let record = sqlx::query!(
158 r#"
159 INSERT INTO password_reset_tokens (token, user_id)
160VALUES ($1, $2)
161RETURNING token
162 "#,
163 token,
164 user_id
165 )
166 .fetch_one(&mut *tx)
167 .await?;
168
169 tx.commit().await?;
170
171 Ok(record.token)
172}
173
174pub async fn get_unused_reset_password_token_with_user_id(
175 conn: &mut PgConnection,
176 user_id: Uuid,
177) -> ModelResult<Option<PasswordResetToken>> {
178 let now = Utc::now();
179 let record = sqlx::query_as!(
180 PasswordResetToken,
181 r#"
182SELECT token,
183 user_id,
184 created_at,
185 updated_at,
186 used_at,
187 deleted_at,
188 expires_at
189FROM password_reset_tokens
190WHERE user_id = $1
191 AND deleted_at IS NULL
192 AND used_at IS NULL
193 AND expires_at > $2
194 "#,
195 user_id,
196 now
197 )
198 .fetch_optional(conn)
199 .await?;
200
201 Ok(record)
202}
203
204pub async fn is_reset_password_token_valid(
205 conn: &mut PgConnection,
206 token: &Uuid,
207) -> ModelResult<bool> {
208 let now = Utc::now();
209 let record = sqlx::query!(
210 r#"
211SELECT *
212FROM password_reset_tokens
213WHERE token = $1
214 AND deleted_at IS NULL
215 AND used_at IS NULL
216 AND expires_at > $2
217 "#,
218 token,
219 now
220 )
221 .fetch_optional(conn)
222 .await?;
223
224 Ok(record.is_some())
225}
226
227pub async fn change_user_password_with_password_reset_token(
228 conn: &mut PgConnection,
229 token: Uuid,
230 password_hash: &SecretString,
231 tmc_client: &TmcClient,
232) -> ModelResult<bool> {
233 let mut tx = conn.begin().await?;
235
236 let record = sqlx::query!(
238 r#"
239SELECT user_id
240FROM password_reset_tokens
241WHERE token = $1
242 AND deleted_at IS NULL
243 AND used_at IS NULL
244 AND expires_at > NOW()
245FOR UPDATE
246 "#,
247 token
248 )
249 .fetch_optional(&mut *tx)
250 .await?;
251
252 let user_id = match record {
253 Some(r) => r.user_id,
254 None => return Ok(false),
255 };
256
257 let had_existing_password = sqlx::query!(
259 r#"
260SELECT *
261FROM user_passwords
262WHERE user_id = $1
263AND deleted_at IS NULL
264 "#,
265 user_id
266 )
267 .fetch_optional(&mut *tx)
268 .await?
269 .is_some();
270
271 upsert_user_password(&mut tx, user_id, password_hash).await?;
273
274 mark_token_used(&mut tx, token).await?;
276
277 let user = get_by_id(&mut tx, user_id).await?;
279
280 tx.commit().await?;
281
282 if !had_existing_password {
284 if let Some(upstream_id) = user.upstream_id {
285 if let Err(e) = tmc_client
286 .set_user_password_managed_by_courses_mooc_fi(upstream_id.to_string(), user_id)
287 .await
288 {
289 warn!(
290 "Failed to notify TMC about password ownership change: {:?}",
291 e
292 );
293 }
294 } else {
295 warn!("User has no upstream_id; skipping TMC notification");
296 }
297 }
298
299 Ok(true)
300}
301
302pub async fn change_user_password_with_old_password(
303 conn: &mut PgConnection,
304 user_id: Uuid,
305 old_password: &SecretString,
306 new_password_hash: &SecretString,
307) -> ModelResult<bool> {
308 let mut tx = conn.begin().await?;
309
310 let is_valid = verify_user_password(&mut tx, user_id, old_password).await?;
312 if !is_valid {
313 return Ok(false);
314 }
315
316 upsert_user_password(&mut tx, user_id, new_password_hash).await?;
318
319 tx.commit().await?;
320
321 Ok(true)
322}
323
324pub async fn mark_token_used(conn: &mut PgConnection, token: Uuid) -> ModelResult<bool> {
325 let result = sqlx::query!(
326 r#"
327UPDATE password_reset_tokens
328SET used_at = NOW(),
329 deleted_at = NOW()
330WHERE token = $1
331 AND deleted_at IS NULL
332 AND used_at IS NULL
333 "#,
334 token
335 )
336 .execute(conn)
337 .await?;
338
339 Ok(result.rows_affected() > 0)
340}