headless_lms_models/
user_passwords.rs

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            // Perform a dummy verify to normalize timing when user has no password row.
83            // This mitigates timing attack vulnerabilities.
84
85            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    // Soft delete possible previous tokens so that only one token is at use at a time
138    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    // Attempt to insert new token; the unique index ensures no more than one active token per user
151    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    // Start a transaction and lock the token row
228    let mut tx = conn.begin().await?;
229
230    // Check if token is valid
231    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    // Check if the user has an existing password
252    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 the new password
266    upsert_user_password(&mut tx, user_id, password_hash).await?;
267
268    // Mark the token as used
269    mark_token_used(&mut tx, token).await?;
270
271    // Fetch user
272    let user = get_by_id(&mut tx, user_id).await?;
273
274    tx.commit().await?;
275
276    // If user didn't have a password stored previously, notify tmc that password is now managed by courses.mooc
277    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    // Verify old password
305    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 the new password
311    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}