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).unwrap();
94 let _ = Argon2::default().verify_password(b"dummy-password", &parsed);
95 return Ok(false);
96 }
97 };
98
99 let parsed_hash = match PasswordHash::new(&user_password.password_hash) {
100 Ok(hash) => hash,
101 Err(_) => return Ok(false),
102 };
103
104 let is_valid = Argon2::default()
105 .verify_password(password.expose_secret().as_bytes(), &parsed_hash)
106 .is_ok();
107
108 Ok(is_valid)
109}
110
111pub async fn check_if_users_password_is_stored(
112 conn: &mut PgConnection,
113 user_id: Uuid,
114) -> ModelResult<bool> {
115 let result = sqlx::query!(
116 r#"
117SELECT *
118FROM user_passwords
119WHERE user_id = $1
120 AND deleted_at IS NULL
121 "#,
122 user_id
123 )
124 .fetch_optional(conn)
125 .await?;
126
127 Ok(result.is_some())
128}
129
130pub async fn insert_password_reset_token(
131 conn: &mut PgConnection,
132 user_id: Uuid,
133 token: Uuid,
134) -> ModelResult<Uuid> {
135 let mut tx = conn.begin().await?;
136
137 let _ = sqlx::query!(
139 r#"
140 UPDATE password_reset_tokens
141SET deleted_at = NOW()
142WHERE user_id = $1
143 AND deleted_at IS NULL
144 "#,
145 user_id
146 )
147 .execute(&mut *tx)
148 .await?;
149
150 let record = sqlx::query!(
152 r#"
153 INSERT INTO password_reset_tokens (token, user_id)
154VALUES ($1, $2)
155RETURNING token
156 "#,
157 token,
158 user_id
159 )
160 .fetch_one(&mut *tx)
161 .await?;
162
163 tx.commit().await?;
164
165 Ok(record.token)
166}
167
168pub async fn get_unused_reset_password_token_with_user_id(
169 conn: &mut PgConnection,
170 user_id: Uuid,
171) -> ModelResult<Option<PasswordResetToken>> {
172 let now = Utc::now();
173 let record = sqlx::query_as!(
174 PasswordResetToken,
175 r#"
176SELECT token,
177 user_id,
178 created_at,
179 updated_at,
180 used_at,
181 deleted_at,
182 expires_at
183FROM password_reset_tokens
184WHERE user_id = $1
185 AND deleted_at IS NULL
186 AND used_at IS NULL
187 AND expires_at > $2
188 "#,
189 user_id,
190 now
191 )
192 .fetch_optional(conn)
193 .await?;
194
195 Ok(record)
196}
197
198pub async fn is_reset_password_token_valid(
199 conn: &mut PgConnection,
200 token: &Uuid,
201) -> ModelResult<bool> {
202 let now = Utc::now();
203 let record = sqlx::query!(
204 r#"
205SELECT *
206FROM password_reset_tokens
207WHERE token = $1
208 AND deleted_at IS NULL
209 AND used_at IS NULL
210 AND expires_at > $2
211 "#,
212 token,
213 now
214 )
215 .fetch_optional(conn)
216 .await?;
217
218 Ok(record.is_some())
219}
220
221pub async fn change_user_password_with_password_reset_token(
222 conn: &mut PgConnection,
223 token: Uuid,
224 password_hash: &SecretString,
225 tmc_client: &TmcClient,
226) -> ModelResult<bool> {
227 let mut tx = conn.begin().await?;
229
230 let record = sqlx::query!(
232 r#"
233SELECT user_id
234FROM password_reset_tokens
235WHERE token = $1
236 AND deleted_at IS NULL
237 AND used_at IS NULL
238 AND expires_at > NOW()
239FOR UPDATE
240 "#,
241 token
242 )
243 .fetch_optional(&mut *tx)
244 .await?;
245
246 let user_id = match record {
247 Some(r) => r.user_id,
248 None => return Ok(false),
249 };
250
251 let had_existing_password = sqlx::query!(
253 r#"
254SELECT *
255FROM user_passwords
256WHERE user_id = $1
257AND deleted_at IS NULL
258 "#,
259 user_id
260 )
261 .fetch_optional(&mut *tx)
262 .await?
263 .is_some();
264
265 upsert_user_password(&mut tx, user_id, password_hash).await?;
267
268 mark_token_used(&mut tx, token).await?;
270
271 let user = get_by_id(&mut tx, user_id).await?;
273
274 tx.commit().await?;
275
276 if !had_existing_password {
278 if let Some(upstream_id) = user.upstream_id {
279 if let Err(e) = tmc_client
280 .set_user_password_managed_by_courses_mooc_fi(upstream_id.to_string(), user_id)
281 .await
282 {
283 warn!(
284 "Failed to notify TMC about password ownership change: {:?}",
285 e
286 );
287 }
288 } else {
289 warn!("User has no upstream_id; skipping TMC notification");
290 }
291 }
292
293 Ok(true)
294}
295
296pub async fn change_user_password_with_old_password(
297 conn: &mut PgConnection,
298 user_id: Uuid,
299 old_password: &SecretString,
300 new_password_hash: &SecretString,
301) -> ModelResult<bool> {
302 let mut tx = conn.begin().await?;
303
304 let is_valid = verify_user_password(&mut tx, user_id, old_password).await?;
306 if !is_valid {
307 return Ok(false);
308 }
309
310 upsert_user_password(&mut tx, user_id, new_password_hash).await?;
312
313 tx.commit().await?;
314
315 Ok(true)
316}
317
318pub async fn mark_token_used(conn: &mut PgConnection, token: Uuid) -> ModelResult<bool> {
319 let result = sqlx::query!(
320 r#"
321UPDATE password_reset_tokens
322SET used_at = NOW(),
323 deleted_at = NOW()
324WHERE token = $1
325 AND deleted_at IS NULL
326 AND used_at IS NULL
327 "#,
328 token
329 )
330 .execute(conn)
331 .await?;
332
333 Ok(result.rows_affected() > 0)
334}