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
10fn normalize_password(password: &SecretString) -> SecretString {
18 SecretString::new(password.expose_secret().nfc().collect::<String>().into())
19}
20
21static 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
31fn legacy_raw_fallback_active(now: DateTime<Utc>) -> bool {
33 now < *LEGACY_RAW_PASSWORD_FALLBACK_UNTIL
34}
35
36enum PasswordVerifyResult {
38 NoMatch,
40 MatchedNormalized,
42 MatchedLegacyRaw,
45}
46
47fn 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 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 if argon2
74 .verify_password(normalized.expose_secret().as_bytes(), parsed_hash)
75 .is_ok()
76 {
77 return PasswordVerifyResult::MatchedNormalized;
78 }
79
80 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
130async 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 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 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 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 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 let mut tx = conn.begin().await?;
373
374 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 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_user_password(&mut tx, user_id, password_hash).await?;
411
412 mark_token_used(&mut tx, token).await?;
414
415 let user = get_by_id(&mut tx, user_id).await?;
417
418 tx.commit().await?;
419
420 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 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_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 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 assert_ne!(NFC_PASSWORD.as_bytes(), NFD_PASSWORD.as_bytes());
498 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 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 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 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 assert!(matches!(
531 verify_against_hash(&secret(NFD_PASSWORD), &parsed, true),
532 PasswordVerifyResult::MatchedLegacyRaw
533 ));
534 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 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 assert!(matches!(
550 verify_against_hash(&secret(NFC_PASSWORD), &parsed, try_legacy),
551 PasswordVerifyResult::MatchedNormalized
552 ));
553 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}