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).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    // Soft delete possible previous tokens so that only one token is at use at a time
144    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    // Attempt to insert new token; the unique index ensures no more than one active token per user
157    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    // Start a transaction and lock the token row
234    let mut tx = conn.begin().await?;
235
236    // Check if token is valid
237    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    // Check if the user has an existing password
258    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 the new password
272    upsert_user_password(&mut tx, user_id, password_hash).await?;
273
274    // Mark the token as used
275    mark_token_used(&mut tx, token).await?;
276
277    // Fetch user
278    let user = get_by_id(&mut tx, user_id).await?;
279
280    tx.commit().await?;
281
282    // If user didn't have a password stored previously, notify tmc that password is now managed by courses.mooc
283    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    // Verify old password
311    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 the new password
317    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}