| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | |
| 15 | |
| 16 | |
| 17 | |
| 18 | use std::time::SystemTime; |
| 19 | |
| 20 | use crate::domain::{ |
| 21 | Actor, DomainError, Email, OrgName, Password, PasswordReset, ResetId, ResetSecret, |
| 22 | SessionTokenHash, User, |
| 23 | repository::{OrgRepository, PasswordResetRepository, SessionRepository, UserRepository}, |
| 24 | }; |
| 25 | |
| 26 | use super::{ |
| 27 | error::Result, |
| 28 | login::ABSENT_USER_HASH, |
| 29 | port::{Mail, Mailer, PasswordHasher}, |
| 30 | }; |
| 31 | |
| 32 | |
| 33 | pub const FORGOT_ACKNOWLEDGEMENT: &str = "If that address belongs to an account, a link is on its way. \ |
| 34 | It works once and expires in 30 minutes."; |
| 35 | |
| 36 | |
| 37 | |
| 38 | |
| 39 | |
| 40 | #[derive(Debug)] |
| 41 | pub struct IssuedReset { |
| 42 | pub reset: PasswordReset, |
| 43 | pub secret: ResetSecret, |
| 44 | } |
| 45 | |
| 46 | impl IssuedReset { |
| 47 | |
| 48 | |
| 49 | |
| 50 | |
| 51 | #[must_use] |
| 52 | pub fn link(&self, origin: &str) -> String { |
| 53 | format!( |
| 54 | "{}/auth/reset/{}", |
| 55 | origin.trim_end_matches('/'), |
| 56 | self.secret.reveal() |
| 57 | ) |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | |
| 62 | |
| 63 | |
| 64 | |
| 65 | |
| 66 | |
| 67 | |
| 68 | async fn mint( |
| 69 | user: &User, |
| 70 | now: SystemTime, |
| 71 | resets: &impl PasswordResetRepository, |
| 72 | ) -> Result<IssuedReset> { |
| 73 | resets.invalidate_for_user(&user.id, now).await?; |
| 74 | |
| 75 | let secret = ResetSecret::generate(); |
| 76 | let reset = PasswordReset::new(ResetId::generate(), user.id.clone(), &secret, now); |
| 77 | |
| 78 | resets.save(&reset).await?; |
| 79 | |
| 80 | Ok(IssuedReset { reset, secret }) |
| 81 | } |
| 82 | |
| 83 | |
| 84 | |
| 85 | |
| 86 | |
| 87 | |
| 88 | |
| 89 | |
| 90 | |
| 91 | |
| 92 | |
| 93 | |
| 94 | |
| 95 | |
| 96 | |
| 97 | |
| 98 | |
| 99 | |
| 100 | |
| 101 | |
| 102 | |
| 103 | |
| 104 | |
| 105 | |
| 106 | |
| 107 | pub async fn request_password_reset( |
| 108 | email: &str, |
| 109 | origin: &str, |
| 110 | now: SystemTime, |
| 111 | users: &impl UserRepository, |
| 112 | resets: &impl PasswordResetRepository, |
| 113 | hasher: &impl PasswordHasher, |
| 114 | mailer: &impl Mailer, |
| 115 | ) -> Result<()> { |
| 116 | let user = match Email::new(email) { |
| 117 | Ok(email) => users.find_by_email(&email).await?, |
| 118 | |
| 119 | Err(_) => None, |
| 120 | }; |
| 121 | |
| 122 | |
| 123 | |
| 124 | let _ = hasher.verify( |
| 125 | "", |
| 126 | &crate::domain::PasswordHash::from_trusted(ABSENT_USER_HASH), |
| 127 | ); |
| 128 | |
| 129 | let Some(user) = user.filter(|_| mailer.configured()) else { |
| 130 | return Ok(()); |
| 131 | }; |
| 132 | |
| 133 | let issued = mint(&user, now, resets).await?; |
| 134 | |
| 135 | if let Err(error) = mailer.send(&reset_mail(&user, &issued, origin)).await { |
| 136 | |
| 137 | |
| 138 | eprintln!("steid: a password reset mail could not be sent: {error}"); |
| 139 | } |
| 140 | |
| 141 | Ok(()) |
| 142 | } |
| 143 | |
| 144 | |
| 145 | |
| 146 | |
| 147 | |
| 148 | |
| 149 | fn reset_mail(user: &User, issued: &IssuedReset, origin: &str) -> Mail { |
| 150 | let origin = origin.trim_end_matches('/'); |
| 151 | |
| 152 | Mail { |
| 153 | to: user.email.clone(), |
| 154 | subject: "Reset your password".to_owned(), |
| 155 | body: format!( |
| 156 | "Someone asked to reset the password on your account at {origin}.\n\ |
| 157 | \n\ |
| 158 | Open this link to choose a new one:\n\ |
| 159 | \n\ |
| 160 | {link}\n\ |
| 161 | \n\ |
| 162 | It works once and expires in 30 minutes. If it was not you, nothing has\n\ |
| 163 | changed and there is nothing to do.\n", |
| 164 | link = issued.link(origin), |
| 165 | ), |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | |
| 170 | |
| 171 | |
| 172 | |
| 173 | |
| 174 | |
| 175 | |
| 176 | |
| 177 | |
| 178 | pub async fn mint_reset_for_handle( |
| 179 | handle: &str, |
| 180 | now: SystemTime, |
| 181 | users: &impl UserRepository, |
| 182 | orgs: &impl OrgRepository, |
| 183 | resets: &impl PasswordResetRepository, |
| 184 | ) -> Result<Option<IssuedReset>> { |
| 185 | let Ok(name) = OrgName::new(handle) else { |
| 186 | return Ok(None); |
| 187 | }; |
| 188 | |
| 189 | let Some(org) = orgs.find_by_name(&name).await? else { |
| 190 | return Ok(None); |
| 191 | }; |
| 192 | |
| 193 | let Some(user) = users.find_by_personal_org(&org.id).await? else { |
| 194 | return Ok(None); |
| 195 | }; |
| 196 | |
| 197 | Ok(Some(mint(&user, now, resets).await?)) |
| 198 | } |
| 199 | |
| 200 | |
| 201 | |
| 202 | |
| 203 | |
| 204 | |
| 205 | pub async fn find_live_reset( |
| 206 | presented: &str, |
| 207 | now: SystemTime, |
| 208 | resets: &impl PasswordResetRepository, |
| 209 | ) -> Result<PasswordReset> { |
| 210 | let hash = ResetSecret::from_presented(presented).hash(); |
| 211 | |
| 212 | resets |
| 213 | .find_by_hash(&hash) |
| 214 | .await? |
| 215 | .filter(|reset| reset.is_live(now)) |
| 216 | .ok_or_else(|| { |
| 217 | DomainError::NotFound { |
| 218 | entity: "reset link", |
| 219 | } |
| 220 | .into() |
| 221 | }) |
| 222 | } |
| 223 | |
| 224 | |
| 225 | |
| 226 | |
| 227 | |
| 228 | |
| 229 | |
| 230 | |
| 231 | |
| 232 | |
| 233 | |
| 234 | |
| 235 | |
| 236 | #[allow(clippy::too_many_arguments)] |
| 237 | pub async fn reset_password( |
| 238 | presented: &str, |
| 239 | new_password: &str, |
| 240 | confirmation: &str, |
| 241 | now: SystemTime, |
| 242 | users: &impl UserRepository, |
| 243 | resets: &impl PasswordResetRepository, |
| 244 | sessions: &impl SessionRepository, |
| 245 | hasher: &impl PasswordHasher, |
| 246 | ) -> Result<()> { |
| 247 | let reset = find_live_reset(presented, now, resets).await?; |
| 248 | |
| 249 | |
| 250 | |
| 251 | let hash = validated(new_password, confirmation, hasher)?; |
| 252 | |
| 253 | let Some(mut user) = users.find_by_id(&reset.user_id).await? else { |
| 254 | |
| 255 | |
| 256 | return Err(DomainError::NotFound { |
| 257 | entity: "reset link", |
| 258 | } |
| 259 | .into()); |
| 260 | }; |
| 261 | |
| 262 | user.password_hash = hash; |
| 263 | users.save(&user).await?; |
| 264 | |
| 265 | resets.mark_used(&reset.id, now).await?; |
| 266 | sessions.delete_by_user(&user.id, None).await?; |
| 267 | |
| 268 | Ok(()) |
| 269 | } |
| 270 | |
| 271 | |
| 272 | |
| 273 | |
| 274 | |
| 275 | |
| 276 | |
| 277 | |
| 278 | |
| 279 | |
| 280 | |
| 281 | |
| 282 | |
| 283 | |
| 284 | #[allow(clippy::too_many_arguments)] |
| 285 | pub async fn change_password( |
| 286 | actor: &Actor, |
| 287 | current_password: &str, |
| 288 | new_password: &str, |
| 289 | confirmation: &str, |
| 290 | here: Option<&SessionTokenHash>, |
| 291 | now: SystemTime, |
| 292 | users: &impl UserRepository, |
| 293 | resets: &impl PasswordResetRepository, |
| 294 | sessions: &impl SessionRepository, |
| 295 | hasher: &impl PasswordHasher, |
| 296 | ) -> Result<()> { |
| 297 | let Some(user_id) = actor.user_id() else { |
| 298 | return Err(DomainError::Forbidden.into()); |
| 299 | }; |
| 300 | |
| 301 | let Some(mut user) = users.find_by_id(user_id).await? else { |
| 302 | return Err(DomainError::Forbidden.into()); |
| 303 | }; |
| 304 | |
| 305 | if !hasher.verify(current_password, &user.password_hash)? { |
| 306 | return Err(DomainError::InvalidCredentials.into()); |
| 307 | } |
| 308 | |
| 309 | user.password_hash = validated(new_password, confirmation, hasher)?; |
| 310 | users.save(&user).await?; |
| 311 | |
| 312 | |
| 313 | resets.invalidate_for_user(&user.id, now).await?; |
| 314 | sessions.delete_by_user(&user.id, here).await?; |
| 315 | |
| 316 | Ok(()) |
| 317 | } |
| 318 | |
| 319 | |
| 320 | |
| 321 | |
| 322 | |
| 323 | |
| 324 | |
| 325 | |
| 326 | |
| 327 | |
| 328 | |
| 329 | |
| 330 | fn validated( |
| 331 | new_password: &str, |
| 332 | confirmation: &str, |
| 333 | hasher: &impl PasswordHasher, |
| 334 | ) -> Result<crate::domain::PasswordHash> { |
| 335 | if new_password != confirmation { |
| 336 | return Err(DomainError::validation("password", "the two did not match").into()); |
| 337 | } |
| 338 | |
| 339 | let password = Password::new(new_password)?; |
| 340 | |
| 341 | Ok(hasher.hash(password.expose())?) |
| 342 | } |
| 343 | |
| 344 | #[cfg(test)] |
| 345 | mod tests { |
| 346 | use std::time::Duration; |
| 347 | |
| 348 | use super::*; |
| 349 | use crate::{ |
| 350 | application::error::Error, |
| 351 | domain::{OrgId, Organization, Session, UserId}, |
| 352 | infrastructure::{ |
| 353 | mail::RecordingMailer, |
| 354 | password::StubHasher, |
| 355 | repository::{ |
| 356 | InMemoryOrgRepo, InMemoryPasswordResetRepo, InMemorySessionRepo, InMemoryUserRepo, |
| 357 | }, |
| 358 | }, |
| 359 | }; |
| 360 | |
| 361 | const ORIGIN: &str = "https://steid.example"; |
| 362 | |
| 363 | fn at(seconds: u64) -> SystemTime { |
| 364 | SystemTime::UNIX_EPOCH + Duration::from_secs(seconds) |
| 365 | } |
| 366 | |
| 367 | struct Fixture { |
| 368 | users: InMemoryUserRepo, |
| 369 | orgs: InMemoryOrgRepo, |
| 370 | resets: InMemoryPasswordResetRepo, |
| 371 | sessions: InMemorySessionRepo, |
| 372 | hasher: StubHasher, |
| 373 | mailer: RecordingMailer, |
| 374 | user: User, |
| 375 | } |
| 376 | |
| 377 | impl Fixture { |
| 378 | async fn new() -> Self { |
| 379 | let hasher = StubHasher::new(); |
| 380 | let orgs = InMemoryOrgRepo::new(); |
| 381 | let users = InMemoryUserRepo::new(); |
| 382 | |
| 383 | let org = Organization::new(OrgId::generate(), "ada", None).expect("valid org"); |
| 384 | orgs.save(&org).await.expect("save org"); |
| 385 | |
| 386 | let user = User::new( |
| 387 | UserId::generate(), |
| 388 | Email::new("ada@example.com").expect("valid email"), |
| 389 | hasher.hash("hunter2").expect("hash"), |
| 390 | org.id.clone(), |
| 391 | ); |
| 392 | users.save(&user).await.expect("save user"); |
| 393 | |
| 394 | Self { |
| 395 | users, |
| 396 | orgs, |
| 397 | resets: InMemoryPasswordResetRepo::new(), |
| 398 | sessions: InMemorySessionRepo::new(), |
| 399 | hasher, |
| 400 | mailer: RecordingMailer::new(), |
| 401 | user, |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | async fn forget(&self, email: &str) -> Result<()> { |
| 406 | request_password_reset( |
| 407 | email, |
| 408 | ORIGIN, |
| 409 | at(1_000), |
| 410 | &self.users, |
| 411 | &self.resets, |
| 412 | &self.hasher, |
| 413 | &self.mailer, |
| 414 | ) |
| 415 | .await |
| 416 | } |
| 417 | |
| 418 | |
| 419 | fn token_from_mail(&self) -> String { |
| 420 | let sent = self.mailer.sent(); |
| 421 | let body = &sent.first().expect("one mail").body; |
| 422 | let prefix = format!("{ORIGIN}/auth/reset/"); |
| 423 | |
| 424 | body.split_whitespace() |
| 425 | .find_map(|word| word.strip_prefix(&prefix)) |
| 426 | .expect("the mail carries a link") |
| 427 | .to_owned() |
| 428 | } |
| 429 | |
| 430 | async fn session(&self, hash: &str) { |
| 431 | self.sessions |
| 432 | .save(&Session::new( |
| 433 | SessionTokenHash::from_trusted(hash), |
| 434 | self.user.id.clone(), |
| 435 | at(90_000), |
| 436 | )) |
| 437 | .await |
| 438 | .expect("save session"); |
| 439 | } |
| 440 | |
| 441 | async fn live_sessions(&self) -> Vec<String> { |
| 442 | let mut live = Vec::new(); |
| 443 | for hash in ["here", "phone", "laptop"] { |
| 444 | if self |
| 445 | .sessions |
| 446 | .find(&SessionTokenHash::from_trusted(hash)) |
| 447 | .await |
| 448 | .expect("read") |
| 449 | .is_some() |
| 450 | { |
| 451 | live.push(hash.to_owned()); |
| 452 | } |
| 453 | } |
| 454 | live |
| 455 | } |
| 456 | |
| 457 | async fn stored_password(&self) -> crate::domain::PasswordHash { |
| 458 | self.users |
| 459 | .find_by_id(&self.user.id) |
| 460 | .await |
| 461 | .expect("read") |
| 462 | .expect("the user exists") |
| 463 | .password_hash |
| 464 | } |
| 465 | } |
| 466 | |
| 467 | |
| 468 | |
| 469 | #[tokio::test] |
| 470 | async fn a_known_address_is_mailed_a_link() { |
| 471 | let f = Fixture::new().await; |
| 472 | |
| 473 | f.forget("ada@example.com").await.expect("should succeed"); |
| 474 | |
| 475 | let sent = f.mailer.sent(); |
| 476 | assert_eq!(sent.len(), 1); |
| 477 | assert_eq!(sent[0].to, f.user.email); |
| 478 | assert!( |
| 479 | sent[0].body.contains(&format!("{ORIGIN}/auth/reset/")), |
| 480 | "{}", |
| 481 | sent[0].body |
| 482 | ); |
| 483 | } |
| 484 | |
| 485 | #[tokio::test] |
| 486 | async fn an_unknown_address_answers_exactly_as_a_known_one_does() { |
| 487 | |
| 488 | |
| 489 | let f = Fixture::new().await; |
| 490 | |
| 491 | assert!(f.forget("nobody@example.com").await.is_ok()); |
| 492 | assert!(f.forget("not-an-email-at-all").await.is_ok()); |
| 493 | assert!(f.forget("").await.is_ok()); |
| 494 | |
| 495 | assert!(f.mailer.sent().is_empty(), "nothing should have been sent"); |
| 496 | } |
| 497 | |
| 498 | #[tokio::test] |
| 499 | async fn the_address_is_normalised_before_it_is_looked_up() { |
| 500 | let f = Fixture::new().await; |
| 501 | |
| 502 | f.forget(" ADA@Example.COM ") |
| 503 | .await |
| 504 | .expect("should succeed"); |
| 505 | |
| 506 | assert_eq!(f.mailer.sent().len(), 1); |
| 507 | } |
| 508 | |
| 509 | #[tokio::test] |
| 510 | async fn the_mail_carries_the_link_and_nothing_else_about_the_account() { |
| 511 | let f = Fixture::new().await; |
| 512 | |
| 513 | f.forget("ada@example.com").await.expect("should succeed"); |
| 514 | |
| 515 | let body = f.mailer.sent()[0].body.clone(); |
| 516 | assert!(!body.contains("ada@example.com"), "{body}"); |
| 517 | assert!(!body.contains("@ada"), "{body}"); |
| 518 | } |
| 519 | |
| 520 | #[tokio::test] |
| 521 | async fn an_instance_that_cannot_send_mail_mints_nothing() { |
| 522 | let f = Fixture { |
| 523 | mailer: RecordingMailer::unconfigured(), |
| 524 | ..Fixture::new().await |
| 525 | }; |
| 526 | |
| 527 | request_password_reset( |
| 528 | "ada@example.com", |
| 529 | ORIGIN, |
| 530 | at(1_000), |
| 531 | &f.users, |
| 532 | &f.resets, |
| 533 | &f.hasher, |
| 534 | &f.mailer, |
| 535 | ) |
| 536 | .await |
| 537 | .expect("should still succeed"); |
| 538 | |
| 539 | assert!(f.mailer.sent().is_empty()); |
| 540 | assert!( |
| 541 | find_live_reset("anything", at(1_000), &f.resets) |
| 542 | .await |
| 543 | .is_err(), |
| 544 | "nothing should have been minted" |
| 545 | ); |
| 546 | } |
| 547 | |
| 548 | #[tokio::test] |
| 549 | async fn a_relay_that_refuses_does_not_change_the_answer() { |
| 550 | |
| 551 | let f = Fixture::new().await; |
| 552 | let broken = RecordingMailer::unconfigured(); |
| 553 | |
| 554 | |
| 555 | |
| 556 | assert!( |
| 557 | request_password_reset( |
| 558 | "ada@example.com", |
| 559 | ORIGIN, |
| 560 | at(1_000), |
| 561 | &f.users, |
| 562 | &f.resets, |
| 563 | &f.hasher, |
| 564 | &broken, |
| 565 | ) |
| 566 | .await |
| 567 | .is_ok() |
| 568 | ); |
| 569 | } |
| 570 | |
| 571 | #[tokio::test] |
| 572 | async fn asking_again_retires_the_first_link() { |
| 573 | let f = Fixture::new().await; |
| 574 | |
| 575 | f.forget("ada@example.com").await.expect("first"); |
| 576 | let first = f.token_from_mail(); |
| 577 | |
| 578 | f.forget("ada@example.com").await.expect("second"); |
| 579 | let sent = f.mailer.sent(); |
| 580 | assert_eq!(sent.len(), 2); |
| 581 | |
| 582 | assert!( |
| 583 | find_live_reset(&first, at(1_000), &f.resets).await.is_err(), |
| 584 | "the first link should be dead once a second is issued" |
| 585 | ); |
| 586 | } |
| 587 | |
| 588 | |
| 589 | |
| 590 | #[tokio::test] |
| 591 | async fn a_link_sets_the_password_and_then_stops_working() { |
| 592 | let f = Fixture::new().await; |
| 593 | f.session("here").await; |
| 594 | f.forget("ada@example.com").await.expect("forget"); |
| 595 | let token = f.token_from_mail(); |
| 596 | |
| 597 | reset_password( |
| 598 | &token, |
| 599 | "correcthorse", |
| 600 | "correcthorse", |
| 601 | at(1_100), |
| 602 | &f.users, |
| 603 | &f.resets, |
| 604 | &f.sessions, |
| 605 | &f.hasher, |
| 606 | ) |
| 607 | .await |
| 608 | .expect("should reset"); |
| 609 | |
| 610 | assert!( |
| 611 | f.hasher |
| 612 | .verify("correcthorse", &f.stored_password().await) |
| 613 | .expect("verify") |
| 614 | ); |
| 615 | assert!( |
| 616 | reset_password( |
| 617 | &token, |
| 618 | "again", |
| 619 | "again", |
| 620 | at(1_200), |
| 621 | &f.users, |
| 622 | &f.resets, |
| 623 | &f.sessions, |
| 624 | &f.hasher, |
| 625 | ) |
| 626 | .await |
| 627 | .is_err(), |
| 628 | "a spent link must not work twice" |
| 629 | ); |
| 630 | } |
| 631 | |
| 632 | #[tokio::test] |
| 633 | async fn a_reset_ends_every_session_including_the_one_asking() { |
| 634 | let f = Fixture::new().await; |
| 635 | for hash in ["here", "phone", "laptop"] { |
| 636 | f.session(hash).await; |
| 637 | } |
| 638 | f.forget("ada@example.com").await.expect("forget"); |
| 639 | |
| 640 | reset_password( |
| 641 | &f.token_from_mail(), |
| 642 | "correcthorse", |
| 643 | "correcthorse", |
| 644 | at(1_100), |
| 645 | &f.users, |
| 646 | &f.resets, |
| 647 | &f.sessions, |
| 648 | &f.hasher, |
| 649 | ) |
| 650 | .await |
| 651 | .expect("should reset"); |
| 652 | |
| 653 | assert!(f.live_sessions().await.is_empty()); |
| 654 | } |
| 655 | |
| 656 | #[tokio::test] |
| 657 | async fn a_link_dies_at_thirty_minutes() { |
| 658 | let f = Fixture::new().await; |
| 659 | f.forget("ada@example.com").await.expect("forget"); |
| 660 | let token = f.token_from_mail(); |
| 661 | let expires = at(1_000) + PasswordReset::LIFETIME; |
| 662 | |
| 663 | assert!( |
| 664 | find_live_reset(&token, expires - Duration::from_secs(1), &f.resets) |
| 665 | .await |
| 666 | .is_ok() |
| 667 | ); |
| 668 | assert!(find_live_reset(&token, expires, &f.resets).await.is_err()); |
| 669 | assert!( |
| 670 | reset_password( |
| 671 | &token, |
| 672 | "correcthorse", |
| 673 | "correcthorse", |
| 674 | expires, |
| 675 | &f.users, |
| 676 | &f.resets, |
| 677 | &f.sessions, |
| 678 | &f.hasher, |
| 679 | ) |
| 680 | .await |
| 681 | .is_err() |
| 682 | ); |
| 683 | } |
| 684 | |
| 685 | #[tokio::test] |
| 686 | async fn an_unknown_token_is_not_found_like_every_other_dead_one() { |
| 687 | let f = Fixture::new().await; |
| 688 | |
| 689 | for token in ["", "nonsense", ResetSecret::generate().reveal()] { |
| 690 | let error = find_live_reset(token, at(1_000), &f.resets) |
| 691 | .await |
| 692 | .expect_err("should refuse"); |
| 693 | |
| 694 | assert!( |
| 695 | matches!( |
| 696 | error, |
| 697 | Error::Domain(DomainError::NotFound { |
| 698 | entity: "reset link" |
| 699 | }) |
| 700 | ), |
| 701 | "{token:?} gave {error:?}" |
| 702 | ); |
| 703 | } |
| 704 | } |
| 705 | |
| 706 | #[tokio::test] |
| 707 | async fn a_mistyped_confirmation_refuses_without_spending_the_link() { |
| 708 | let f = Fixture::new().await; |
| 709 | f.forget("ada@example.com").await.expect("forget"); |
| 710 | let token = f.token_from_mail(); |
| 711 | |
| 712 | assert!( |
| 713 | reset_password( |
| 714 | &token, |
| 715 | "correcthorse", |
| 716 | "correcthorsr", |
| 717 | at(1_100), |
| 718 | &f.users, |
| 719 | &f.resets, |
| 720 | &f.sessions, |
| 721 | &f.hasher, |
| 722 | ) |
| 723 | .await |
| 724 | .is_err() |
| 725 | ); |
| 726 | assert!( |
| 727 | find_live_reset(&token, at(1_100), &f.resets).await.is_ok(), |
| 728 | "the link should survive a typo" |
| 729 | ); |
| 730 | assert!( |
| 731 | f.hasher |
| 732 | .verify("hunter2", &f.stored_password().await) |
| 733 | .expect("verify"), |
| 734 | "the old password should still be the password" |
| 735 | ); |
| 736 | } |
| 737 | |
| 738 | #[tokio::test] |
| 739 | async fn a_password_short_of_the_rule_is_refused_and_the_link_survives() { |
| 740 | |
| 741 | |
| 742 | let f = Fixture::new().await; |
| 743 | f.forget("ada@example.com").await.expect("forget"); |
| 744 | let token = f.token_from_mail(); |
| 745 | |
| 746 | for short in ["", "hunter2", "elevenchars"] { |
| 747 | let error = reset_password( |
| 748 | &token, |
| 749 | short, |
| 750 | short, |
| 751 | at(1_100), |
| 752 | &f.users, |
| 753 | &f.resets, |
| 754 | &f.sessions, |
| 755 | &f.hasher, |
| 756 | ) |
| 757 | .await |
| 758 | .expect_err("should refuse"); |
| 759 | |
| 760 | assert!(matches!( |
| 761 | error, |
| 762 | Error::Domain(DomainError::Validation { ref field, .. }) if field == "password" |
| 763 | )); |
| 764 | } |
| 765 | |
| 766 | assert!( |
| 767 | reset_password( |
| 768 | &token, |
| 769 | "correcthorse", |
| 770 | "correcthorse", |
| 771 | at(1_100), |
| 772 | &f.users, |
| 773 | &f.resets, |
| 774 | &f.sessions, |
| 775 | &f.hasher, |
| 776 | ) |
| 777 | .await |
| 778 | .is_ok(), |
| 779 | "twelve characters is enough, and the link still worked" |
| 780 | ); |
| 781 | } |
| 782 | |
| 783 | #[tokio::test] |
| 784 | async fn a_change_to_a_short_password_is_refused_and_keeps_the_old_one() { |
| 785 | let f = Fixture::new().await; |
| 786 | |
| 787 | let error = change_password( |
| 788 | &Actor::User(f.user.id.clone()), |
| 789 | "hunter2", |
| 790 | "hunter3", |
| 791 | "hunter3", |
| 792 | None, |
| 793 | at(1_000), |
| 794 | &f.users, |
| 795 | &f.resets, |
| 796 | &f.sessions, |
| 797 | &f.hasher, |
| 798 | ) |
| 799 | .await |
| 800 | .expect_err("should refuse"); |
| 801 | |
| 802 | assert!(matches!( |
| 803 | error, |
| 804 | Error::Domain(DomainError::Validation { ref field, .. }) if field == "password" |
| 805 | )); |
| 806 | assert!( |
| 807 | f.hasher |
| 808 | .verify("hunter2", &f.stored_password().await) |
| 809 | .expect("verify"), |
| 810 | "the stored password — itself older than the rule — is untouched" |
| 811 | ); |
| 812 | } |
| 813 | |
| 814 | #[tokio::test] |
| 815 | async fn a_mismatch_is_reported_before_the_length() { |
| 816 | |
| 817 | |
| 818 | let f = Fixture::new().await; |
| 819 | |
| 820 | let error = change_password( |
| 821 | &Actor::User(f.user.id.clone()), |
| 822 | "hunter2", |
| 823 | "short", |
| 824 | "shorter", |
| 825 | None, |
| 826 | at(1_000), |
| 827 | &f.users, |
| 828 | &f.resets, |
| 829 | &f.sessions, |
| 830 | &f.hasher, |
| 831 | ) |
| 832 | .await |
| 833 | .expect_err("should refuse"); |
| 834 | |
| 835 | assert!(matches!( |
| 836 | error, |
| 837 | Error::Domain(DomainError::Validation { ref reason, .. }) if reason.contains("match") |
| 838 | )); |
| 839 | } |
| 840 | |
| 841 | |
| 842 | |
| 843 | #[tokio::test] |
| 844 | async fn a_handle_mints_the_same_kind_of_link() { |
| 845 | let f = Fixture::new().await; |
| 846 | |
| 847 | let issued = mint_reset_for_handle("ada", at(1_000), &f.users, &f.orgs, &f.resets) |
| 848 | .await |
| 849 | .expect("should look up") |
| 850 | .expect("ada exists"); |
| 851 | |
| 852 | assert_eq!( |
| 853 | issued.link(ORIGIN), |
| 854 | format!("{ORIGIN}/auth/reset/{}", issued.secret.reveal()) |
| 855 | ); |
| 856 | reset_password( |
| 857 | issued.secret.reveal(), |
| 858 | "correcthorse", |
| 859 | "correcthorse", |
| 860 | at(1_100), |
| 861 | &f.users, |
| 862 | &f.resets, |
| 863 | &f.sessions, |
| 864 | &f.hasher, |
| 865 | ) |
| 866 | .await |
| 867 | .expect("the printed link should work"); |
| 868 | } |
| 869 | |
| 870 | #[tokio::test] |
| 871 | async fn an_unknown_handle_mints_nothing() { |
| 872 | let f = Fixture::new().await; |
| 873 | |
| 874 | for handle in ["grace", "", "not a handle"] { |
| 875 | assert!( |
| 876 | mint_reset_for_handle(handle, at(1_000), &f.users, &f.orgs, &f.resets) |
| 877 | .await |
| 878 | .expect("should not error") |
| 879 | .is_none(), |
| 880 | "{handle:?}" |
| 881 | ); |
| 882 | } |
| 883 | } |
| 884 | |
| 885 | #[tokio::test] |
| 886 | async fn a_trailing_slash_on_the_origin_does_not_double_up() { |
| 887 | let f = Fixture::new().await; |
| 888 | let issued = mint_reset_for_handle("ada", at(1_000), &f.users, &f.orgs, &f.resets) |
| 889 | .await |
| 890 | .expect("look up") |
| 891 | .expect("ada exists"); |
| 892 | |
| 893 | assert_eq!(issued.link("https://steid.example/"), issued.link(ORIGIN)); |
| 894 | } |
| 895 | |
| 896 | |
| 897 | |
| 898 | #[tokio::test] |
| 899 | async fn a_change_needs_the_current_password() { |
| 900 | let f = Fixture::new().await; |
| 901 | |
| 902 | let error = change_password( |
| 903 | &Actor::User(f.user.id.clone()), |
| 904 | "wrong", |
| 905 | "correcthorse", |
| 906 | "correcthorse", |
| 907 | None, |
| 908 | at(1_000), |
| 909 | &f.users, |
| 910 | &f.resets, |
| 911 | &f.sessions, |
| 912 | &f.hasher, |
| 913 | ) |
| 914 | .await |
| 915 | .expect_err("should refuse"); |
| 916 | |
| 917 | assert!(matches!( |
| 918 | error, |
| 919 | Error::Domain(DomainError::InvalidCredentials) |
| 920 | )); |
| 921 | assert!( |
| 922 | f.hasher |
| 923 | .verify("hunter2", &f.stored_password().await) |
| 924 | .expect("verify") |
| 925 | ); |
| 926 | } |
| 927 | |
| 928 | #[tokio::test] |
| 929 | async fn a_change_ends_every_other_session_and_keeps_this_one() { |
| 930 | let f = Fixture::new().await; |
| 931 | for hash in ["here", "phone", "laptop"] { |
| 932 | f.session(hash).await; |
| 933 | } |
| 934 | |
| 935 | change_password( |
| 936 | &Actor::User(f.user.id.clone()), |
| 937 | "hunter2", |
| 938 | "correcthorse", |
| 939 | "correcthorse", |
| 940 | Some(&SessionTokenHash::from_trusted("here")), |
| 941 | at(1_000), |
| 942 | &f.users, |
| 943 | &f.resets, |
| 944 | &f.sessions, |
| 945 | &f.hasher, |
| 946 | ) |
| 947 | .await |
| 948 | .expect("should change"); |
| 949 | |
| 950 | assert_eq!(f.live_sessions().await, vec!["here".to_owned()]); |
| 951 | assert!( |
| 952 | f.hasher |
| 953 | .verify("correcthorse", &f.stored_password().await) |
| 954 | .expect("verify") |
| 955 | ); |
| 956 | } |
| 957 | |
| 958 | #[tokio::test] |
| 959 | async fn a_change_retires_an_outstanding_link() { |
| 960 | |
| 961 | let f = Fixture::new().await; |
| 962 | f.forget("ada@example.com").await.expect("forget"); |
| 963 | let token = f.token_from_mail(); |
| 964 | |
| 965 | change_password( |
| 966 | &Actor::User(f.user.id.clone()), |
| 967 | "hunter2", |
| 968 | "correcthorse", |
| 969 | "correcthorse", |
| 970 | None, |
| 971 | at(1_100), |
| 972 | &f.users, |
| 973 | &f.resets, |
| 974 | &f.sessions, |
| 975 | &f.hasher, |
| 976 | ) |
| 977 | .await |
| 978 | .expect("should change"); |
| 979 | |
| 980 | assert!(find_live_reset(&token, at(1_200), &f.resets).await.is_err()); |
| 981 | } |
| 982 | |
| 983 | #[tokio::test] |
| 984 | async fn a_change_needs_the_two_new_ones_to_match() { |
| 985 | let f = Fixture::new().await; |
| 986 | |
| 987 | assert!( |
| 988 | change_password( |
| 989 | &Actor::User(f.user.id.clone()), |
| 990 | "hunter2", |
| 991 | "correcthorse", |
| 992 | "correcthorsr", |
| 993 | None, |
| 994 | at(1_000), |
| 995 | &f.users, |
| 996 | &f.resets, |
| 997 | &f.sessions, |
| 998 | &f.hasher, |
| 999 | ) |
| 1000 | .await |
| 1001 | .is_err() |
| 1002 | ); |
| 1003 | assert!( |
| 1004 | f.hasher |
| 1005 | .verify("hunter2", &f.stored_password().await) |
| 1006 | .expect("verify") |
| 1007 | ); |
| 1008 | } |
| 1009 | |
| 1010 | #[tokio::test] |
| 1011 | async fn an_anonymous_caller_cannot_change_anything() { |
| 1012 | let f = Fixture::new().await; |
| 1013 | |
| 1014 | let error = change_password( |
| 1015 | &Actor::Anonymous, |
| 1016 | "hunter2", |
| 1017 | "correcthorse", |
| 1018 | "correcthorse", |
| 1019 | None, |
| 1020 | at(1_000), |
| 1021 | &f.users, |
| 1022 | &f.resets, |
| 1023 | &f.sessions, |
| 1024 | &f.hasher, |
| 1025 | ) |
| 1026 | .await |
| 1027 | .expect_err("should refuse"); |
| 1028 | |
| 1029 | assert!(matches!(error, Error::Domain(DomainError::Forbidden))); |
| 1030 | } |
| 1031 | } |