Skip to main content

headless_lms_models/
user_passwords.rs

1use crate::prelude::*;
2use crate::users::get_by_id;
3use argon2::password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash};
4use argon2::{Algorithm, Argon2, Params, Version};
5use headless_lms_utils::tmc::TmcClient;
6use secrecy::{ExposeSecret, SecretString};
7use std::sync::LazyLock;
8use unicode_normalization::UnicodeNormalization;
9
10/// Normalize a password to Unicode NFC before it is hashed or verified.
11///
12/// Argon2 hashes raw bytes, so the same password typed on different platforms/forms can hash
13/// differently when it contains composed characters (e.g. `å`/`ä`/`ö`): NFC `ä` (U+00E4) and
14/// NFD `ä` (U+0061 U+0308) are different byte sequences. Applying NFC at both hash and verify
15/// guarantees storage and checking agree. The normalized form is kept in a `SecretString` so it
16/// is zeroized on drop like every other password value here.
17fn normalize_password(password: &SecretString) -> SecretString {
18    SecretString::new(password.expose_secret().nfc().collect::<String>().into())
19}
20
21/// Passwords whose hash was stored under the pre-normalization (raw byte) form are accepted until
22/// this instant; afterwards only the NFC form is checked and any not-yet-converted user must reset
23/// their password. Set to one year after the normalization rollout — ADJUST to one year after the
24/// actual deploy date.
25static LEGACY_RAW_PASSWORD_FALLBACK_UNTIL: LazyLock<DateTime<Utc>> = LazyLock::new(|| {
26    DateTime::parse_from_rfc3339("2027-06-12T00:00:00Z")
27        .expect("hardcoded fallback deadline must be valid RFC3339")
28        .with_timezone(&Utc)
29});
30
31/// Whether the legacy raw-byte password form is still accepted at `now`.
32fn legacy_raw_fallback_active(now: DateTime<Utc>) -> bool {
33    now < *LEGACY_RAW_PASSWORD_FALLBACK_UNTIL
34}
35
36/// Outcome of verifying a password against a stored Argon2 hash.
37enum PasswordVerifyResult {
38    /// No form of the password matched the stored hash.
39    NoMatch,
40    /// Matched the canonical NFC-normalized form (how hashes are written today).
41    MatchedNormalized,
42    /// Matched only the raw, pre-normalization byte form. The hash should be re-stored under NFC;
43    /// this form is accepted only while [`legacy_raw_fallback_active`] holds.
44    MatchedLegacyRaw,
45}
46
47/// Verify a password against a stored Argon2 hash, tolerant of Unicode normalization.
48///
49/// When NFC normalization does not change the input (the common case, e.g. any pure-ASCII
50/// password) this performs a single verify. Otherwise it checks the NFC form first (how hashes are
51/// written today) and, only if `try_legacy_raw` is set, falls back to the raw submitted bytes (how
52/// hashes created before normalization were written). When `try_legacy_raw` is false the raw form
53/// is not checked at all.
54fn verify_against_hash(
55    password: &SecretString,
56    parsed_hash: &PasswordHash,
57    try_legacy_raw: bool,
58) -> PasswordVerifyResult {
59    let argon2 = Argon2::default();
60    let raw = password.expose_secret();
61    let normalized = normalize_password(password);
62
63    // Normalization is a no-op for this password: there is only one form, so verify once.
64    if normalized.expose_secret() == raw {
65        return if argon2.verify_password(raw.as_bytes(), parsed_hash).is_ok() {
66            PasswordVerifyResult::MatchedNormalized
67        } else {
68            PasswordVerifyResult::NoMatch
69        };
70    }
71
72    // Canonical (NFC) form first.
73    if argon2
74        .verify_password(normalized.expose_secret().as_bytes(), parsed_hash)
75        .is_ok()
76    {
77        return PasswordVerifyResult::MatchedNormalized;
78    }
79
80    // Legacy fallback: only attempted while still within the migration window.
81    if try_legacy_raw && argon2.verify_password(raw.as_bytes(), parsed_hash).is_ok() {
82        return PasswordVerifyResult::MatchedLegacyRaw;
83    }
84
85    PasswordVerifyResult::NoMatch
86}
87
88pub struct UserPassword {
89    pub user_id: Uuid,
90    pub password_hash: SecretString,
91    pub created_at: DateTime<Utc>,
92    pub updated_at: DateTime<Utc>,
93    pub deleted_at: Option<DateTime<Utc>>,
94}
95
96#[derive(sqlx::FromRow, Debug, Clone)]
97pub struct PasswordResetToken {
98    pub id: Uuid,
99    pub token: Uuid,
100    pub user_id: Uuid,
101    pub created_at: DateTime<Utc>,
102    pub updated_at: DateTime<Utc>,
103    pub expires_at: DateTime<Utc>,
104    pub used_at: Option<DateTime<Utc>>,
105    pub deleted_at: Option<DateTime<Utc>>,
106}
107
108pub async fn upsert_user_password(
109    conn: &mut PgConnection,
110    user_id: Uuid,
111    password_hash: &SecretString,
112) -> ModelResult<bool> {
113    let result = sqlx::query!(
114        r#"
115INSERT INTO user_passwords (user_id, password_hash)
116VALUES ($1, $2) ON CONFLICT (user_id) DO
117UPDATE
118SET password_hash = EXCLUDED.password_hash,
119    deleted_at = NULL
120        "#,
121        user_id,
122        password_hash.expose_secret()
123    )
124    .execute(conn)
125    .await?;
126
127    Ok(result.rows_affected() > 0)
128}
129
130/// Re-stores `new_hash` for the user only if the currently stored hash still equals
131/// `expected_current_hash` (a compare-and-swap). Returns `true` if the row was updated and `false`
132/// if the stored hash had already changed (e.g. a concurrent password change) or no active row
133/// matched, in which case nothing is written. Used by the legacy-rehash path so that a concurrent
134/// password change is never clobbered by re-storing a hash derived from the old password.
135async fn update_password_hash_if_unchanged(
136    conn: &mut PgConnection,
137    user_id: Uuid,
138    new_hash: &SecretString,
139    expected_current_hash: &str,
140) -> ModelResult<bool> {
141    let result = sqlx::query!(
142        r#"
143UPDATE user_passwords
144SET password_hash = $2
145WHERE user_id = $1
146  AND password_hash = $3
147  AND deleted_at IS NULL
148        "#,
149        user_id,
150        new_hash.expose_secret(),
151        expected_current_hash,
152    )
153    .execute(conn)
154    .await?;
155
156    Ok(result.rows_affected() > 0)
157}
158
159pub fn hash_password(
160    password: &SecretString,
161) -> Result<SecretString, argon2::password_hash::Error> {
162    let argon2 = Argon2::new(
163        Algorithm::Argon2id,
164        Version::V0x13,
165        Params::new(65536, 3, 4, None)?,
166    );
167
168    let normalized = normalize_password(password);
169    let password_hash = argon2.hash_password(normalized.expose_secret().as_bytes())?;
170    Ok(SecretString::new(password_hash.to_string().into()))
171}
172
173pub async fn verify_user_password(
174    conn: &mut PgConnection,
175    user_id: Uuid,
176    password: &SecretString,
177) -> ModelResult<bool> {
178    let user_password = match sqlx::query!(
179        r#"
180SELECT *
181FROM user_passwords
182WHERE user_id = $1
183  AND deleted_at IS NULL
184        "#,
185        user_id
186    )
187    .fetch_optional(&mut *conn)
188    .await?
189    {
190        Some(p) => p,
191        None => {
192            // Perform a dummy verify to normalize timing when user has no password row.
193            // This mitigates timing attack vulnerabilities.
194
195            static DUMMY_HASH: LazyLock<String> = LazyLock::new(|| {
196                Argon2::default()
197                    .hash_password(b"dummy-password")
198                    .expect("failed to create dummy hash")
199                    .to_string()
200            });
201
202            let parsed = PasswordHash::new(&DUMMY_HASH).map_err(|e| {
203                ModelError::new(
204                    ModelErrorType::Generic,
205                    format!("Failed to parse DUMMY_HASH: {}", e),
206                    Some(anyhow::anyhow!("Password hash error: {}", e)),
207                )
208            })?;
209            let _ = Argon2::default().verify_password(b"dummy-password", &parsed);
210            return Ok(false);
211        }
212    };
213
214    let parsed_hash = match PasswordHash::new(&user_password.password_hash) {
215        Ok(hash) => hash,
216        Err(e) => {
217            warn!("Stored password hash for user {user_id} is malformed: {e}");
218            return Ok(false);
219        }
220    };
221
222    let try_legacy = legacy_raw_fallback_active(Utc::now());
223    match verify_against_hash(password, &parsed_hash, try_legacy) {
224        PasswordVerifyResult::MatchedNormalized => Ok(true),
225        PasswordVerifyResult::NoMatch => Ok(false),
226        PasswordVerifyResult::MatchedLegacyRaw => {
227            // The stored hash is in the old raw-byte form. Re-store it under NFC so the row
228            // converges to the canonical form and survives the fallback deadline. Best-effort: a
229            // failure here is logged but must not fail an otherwise-valid login. The re-store is a
230            // compare-and-swap against the hash we just read, so a password change committed
231            // concurrently (between the read above and this write) is never overwritten by this
232            // rehash of the old password.
233            match hash_password(password) {
234                Ok(new_hash) => match update_password_hash_if_unchanged(
235                    conn,
236                    user_id,
237                    &new_hash,
238                    &user_password.password_hash,
239                )
240                .await
241                {
242                    Ok(true) => {}
243                    Ok(false) => {
244                        info!(
245                            "Skipped legacy password rehash for user {user_id}: stored hash changed concurrently"
246                        );
247                    }
248                    Err(e) => {
249                        warn!("Failed to re-store NFC-normalized password for user {user_id}: {e}");
250                    }
251                },
252                Err(e) => {
253                    warn!("Failed to hash NFC-normalized password for user {user_id}: {e}");
254                }
255            }
256            Ok(true)
257        }
258    }
259}
260
261pub async fn check_if_users_password_is_stored(
262    conn: &mut PgConnection,
263    user_id: Uuid,
264) -> ModelResult<bool> {
265    let result = sqlx::query!(
266        r#"
267SELECT *
268FROM user_passwords
269WHERE user_id = $1
270  AND deleted_at IS NULL
271        "#,
272        user_id
273    )
274    .fetch_optional(conn)
275    .await?;
276
277    Ok(result.is_some())
278}
279
280pub async fn insert_password_reset_token(
281    conn: &mut PgConnection,
282    user_id: Uuid,
283    token: Uuid,
284) -> ModelResult<Uuid> {
285    let mut tx = conn.begin().await?;
286
287    // Soft delete possible previous tokens so that only one token is at use at a time
288    let _ = sqlx::query!(
289        r#"
290   UPDATE password_reset_tokens
291SET deleted_at = NOW()
292WHERE user_id = $1
293  AND deleted_at IS NULL
294    "#,
295        user_id
296    )
297    .execute(&mut *tx)
298    .await?;
299
300    // Attempt to insert new token; the unique index ensures no more than one active token per user
301    let record = sqlx::query!(
302        r#"
303      INSERT INTO password_reset_tokens (token, user_id)
304VALUES ($1, $2)
305RETURNING *
306        "#,
307        token,
308        user_id
309    )
310    .fetch_one(&mut *tx)
311    .await?;
312
313    tx.commit().await?;
314
315    Ok(record.token)
316}
317
318pub async fn get_unused_reset_password_token_with_user_id(
319    conn: &mut PgConnection,
320    user_id: Uuid,
321) -> ModelResult<Option<PasswordResetToken>> {
322    let now = Utc::now();
323    let record = sqlx::query_as!(
324        PasswordResetToken,
325        r#"
326SELECT *
327FROM password_reset_tokens
328WHERE user_id = $1
329  AND deleted_at IS NULL
330  AND used_at IS NULL
331  AND expires_at > $2
332        "#,
333        user_id,
334        now
335    )
336    .fetch_optional(conn)
337    .await?;
338
339    Ok(record)
340}
341
342pub async fn is_reset_password_token_valid(
343    conn: &mut PgConnection,
344    token: &Uuid,
345) -> ModelResult<bool> {
346    let now = Utc::now();
347    let record = sqlx::query!(
348        r#"
349SELECT *
350FROM password_reset_tokens
351WHERE token = $1
352  AND deleted_at IS NULL
353  AND used_at IS NULL
354  AND expires_at > $2
355       "#,
356        token,
357        now
358    )
359    .fetch_optional(conn)
360    .await?;
361
362    Ok(record.is_some())
363}
364
365pub async fn change_user_password_with_password_reset_token(
366    conn: &mut PgConnection,
367    token: Uuid,
368    password_hash: &SecretString,
369    tmc_client: &TmcClient,
370) -> ModelResult<bool> {
371    // Start a transaction and lock the token row
372    let mut tx = conn.begin().await?;
373
374    // Check if token is valid
375    let record = sqlx::query!(
376        r#"
377SELECT *
378FROM password_reset_tokens
379WHERE token = $1
380  AND deleted_at IS NULL
381  AND used_at IS NULL
382  AND expires_at > NOW()
383FOR UPDATE
384        "#,
385        token
386    )
387    .fetch_optional(&mut *tx)
388    .await?;
389
390    let user_id = match record {
391        Some(r) => r.user_id,
392        None => return Ok(false),
393    };
394
395    // Check if the user has an existing password
396    let had_existing_password = sqlx::query!(
397        r#"
398SELECT *
399FROM user_passwords
400WHERE user_id = $1
401AND deleted_at IS NULL
402        "#,
403        user_id
404    )
405    .fetch_optional(&mut *tx)
406    .await?
407    .is_some();
408
409    // Upsert the new password
410    upsert_user_password(&mut tx, user_id, password_hash).await?;
411
412    // Mark the token as used
413    mark_token_used(&mut tx, token).await?;
414
415    // Fetch user
416    let user = get_by_id(&mut tx, user_id).await?;
417
418    tx.commit().await?;
419
420    // If user didn't have a password stored previously, notify tmc that password is now managed by courses.mooc
421    if !had_existing_password {
422        if let Some(upstream_id) = user.upstream_id {
423            if let Err(e) = tmc_client
424                .set_user_password_managed_by_courses_mooc_fi(upstream_id.to_string(), user_id)
425                .await
426            {
427                warn!(
428                    "Failed to notify TMC about password ownership change: {:?}",
429                    e
430                );
431            }
432        } else {
433            warn!("User has no upstream_id; skipping TMC notification");
434        }
435    }
436
437    Ok(true)
438}
439
440pub async fn change_user_password_with_old_password(
441    conn: &mut PgConnection,
442    user_id: Uuid,
443    old_password: &SecretString,
444    new_password_hash: &SecretString,
445) -> ModelResult<bool> {
446    let mut tx = conn.begin().await?;
447
448    // Verify old password
449    let is_valid = verify_user_password(&mut tx, user_id, old_password).await?;
450    if !is_valid {
451        return Ok(false);
452    }
453
454    // Upsert the new password
455    upsert_user_password(&mut tx, user_id, new_password_hash).await?;
456
457    tx.commit().await?;
458
459    Ok(true)
460}
461
462pub async fn mark_token_used(conn: &mut PgConnection, token: Uuid) -> ModelResult<bool> {
463    let result = sqlx::query!(
464        r#"
465UPDATE password_reset_tokens
466SET used_at = NOW(),
467  deleted_at = NOW()
468WHERE token = $1
469  AND deleted_at IS NULL
470  AND used_at IS NULL
471        "#,
472        token
473    )
474    .execute(conn)
475    .await?;
476
477    Ok(result.rows_affected() > 0)
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use argon2::password_hash::PasswordVerifier;
484
485    fn secret(s: &str) -> SecretString {
486        SecretString::new(s.to_string().into())
487    }
488
489    // The same visual password "pässword" in two Unicode forms:
490    // NFC: "ä" = U+00E4 (one code point); NFD: "ä" = U+0061 U+0308 (a + combining diaeresis).
491    const NFC_PASSWORD: &str = "p\u{00e4}ssword";
492    const NFD_PASSWORD: &str = "pa\u{0308}ssword";
493
494    #[test]
495    fn nfc_and_nfd_inputs_normalize_to_identical_bytes() {
496        // The raw inputs differ byte-for-byte...
497        assert_ne!(NFC_PASSWORD.as_bytes(), NFD_PASSWORD.as_bytes());
498        // ...but normalize to the same value, so they will hash identically.
499        let a = normalize_password(&secret(NFC_PASSWORD));
500        let b = normalize_password(&secret(NFD_PASSWORD));
501        assert_eq!(a.expose_secret(), b.expose_secret());
502    }
503
504    #[test]
505    fn hash_of_one_form_verifies_against_the_other_form() {
506        // Hash the NFD form (as a Rails form might submit it)...
507        let hash = hash_password(&secret(NFD_PASSWORD)).expect("hashing should succeed");
508        let parsed = PasswordHash::new(hash.expose_secret()).expect("stored hash should parse");
509        // ...and verify the NFC form (as a browser form might submit it), normalized the same way
510        // verify_user_password does. Without normalization this would fail.
511        let candidate = normalize_password(&secret(NFC_PASSWORD));
512        assert!(
513            Argon2::default()
514                .verify_password(candidate.expose_secret().as_bytes(), &parsed)
515                .is_ok()
516        );
517    }
518
519    #[test]
520    fn legacy_raw_hash_matches_only_when_fallback_enabled() {
521        // Simulate a hash stored BEFORE normalization existed: computed from the raw NFD bytes.
522        let stored = Argon2::default()
523            .hash_password(NFD_PASSWORD.as_bytes())
524            .expect("hashing should succeed")
525            .to_string();
526        let parsed = PasswordHash::new(&stored).expect("stored hash should parse");
527
528        // While the migration window is open, the raw form is recognized as a legacy match
529        // (the caller will then re-store it under NFC).
530        assert!(matches!(
531            verify_against_hash(&secret(NFD_PASSWORD), &parsed, true),
532            PasswordVerifyResult::MatchedLegacyRaw
533        ));
534        // After the deadline the raw form is not checked at all, so the same hash no longer matches.
535        assert!(matches!(
536            verify_against_hash(&secret(NFD_PASSWORD), &parsed, false),
537            PasswordVerifyResult::NoMatch
538        ));
539    }
540
541    #[test]
542    fn normalized_hash_matches_regardless_of_fallback() {
543        // A hash written today is NFC-normalized.
544        let stored = hash_password(&secret(NFD_PASSWORD)).expect("hashing should succeed");
545        let parsed = PasswordHash::new(stored.expose_secret()).expect("stored hash should parse");
546
547        for try_legacy in [true, false] {
548            // The NFC form matches whether or not the legacy fallback is enabled...
549            assert!(matches!(
550                verify_against_hash(&secret(NFC_PASSWORD), &parsed, try_legacy),
551                PasswordVerifyResult::MatchedNormalized
552            ));
553            // ...and a wrong password never matches.
554            assert!(matches!(
555                verify_against_hash(&secret("different"), &parsed, try_legacy),
556                PasswordVerifyResult::NoMatch
557            ));
558        }
559    }
560
561    #[test]
562    fn legacy_fallback_is_time_gated() {
563        let deadline = *LEGACY_RAW_PASSWORD_FALLBACK_UNTIL;
564        assert!(legacy_raw_fallback_active(
565            deadline - chrono::Duration::days(1)
566        ));
567        assert!(!legacy_raw_fallback_active(
568            deadline + chrono::Duration::days(1)
569        ));
570    }
571
572    #[test]
573    fn ascii_password_is_unchanged_by_normalization() {
574        let normalized = normalize_password(&secret("plain-ascii-123"));
575        assert_eq!(normalized.expose_secret(), "plain-ascii-123");
576    }
577
578    #[test]
579    fn wrong_password_still_fails_after_normalization() {
580        let hash = hash_password(&secret(NFC_PASSWORD)).expect("hashing should succeed");
581        let parsed = PasswordHash::new(hash.expose_secret()).expect("stored hash should parse");
582        let wrong = normalize_password(&secret("totally-different"));
583        assert!(
584            Argon2::default()
585                .verify_password(wrong.expose_secret().as_bytes(), &parsed)
586                .is_err()
587        );
588    }
589}