steid

@jamesgill /

steid/src/application/password_reset.rs
Rust32.3 KBCode·Blame·History·Raw
1//! Forgetting a password, and changing one.
2//!
3//! Four ways in, one set of rules:
4//!
5//! - `/auth/forgot` asks for a link by email — [`request_password_reset`];
6//! - `steid reset-password <handle>` mints the same link on the box —
7//! [`mint_reset_for_handle`];
8//! - `/auth/reset/{token}` spends it — [`find_live_reset`] then [`reset_password`];
9//! - `/{handle}/settings` changes a password you still know — [`change_password`].
10//!
11//! Two rules run through all of it. **Nothing here says whether an account exists**: the
12//! forgot path answers identically for a hit and a miss, and does the same hashing work
13//! on both so the answer does not arrive faster for one of them. And **a password that
14//! changes ends sessions** — every other one on a change, every one at all on a reset,
15//! because the person resetting may be doing so precisely because somebody else has the
16//! account.
17
18use std::time::SystemTime;
19
20use crate::domain::{
21 Actor, DomainError, Email, OrgName, Password, PasswordReset, ResetId, ResetSecret,
22 SessionTokenHash, User,
23 repository::{OrgRepository, PasswordResetRepository, SessionRepository, UserRepository},
24};
25
26use super::{
27 error::Result,
28 login::ABSENT_USER_HASH,
29 port::{Mail, Mailer, PasswordHasher},
30};
31
32/// What every response to `/auth/forgot` says, hit or miss.
33pub 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/// A freshly minted link, and the one chance to use it.
37///
38/// The secret is not stored — only its hash is — so a caller that drops this without
39/// mailing or printing it has issued a credential nobody can ever spend.
40#[derive(Debug)]
41pub struct IssuedReset {
42 pub reset: PasswordReset,
43 pub secret: ResetSecret,
44}
45
46impl IssuedReset {
47 /// The link to put in a mail or print on a terminal.
48 ///
49 /// `origin` is the instance's own address — derived from the request when serving,
50 /// configured for the command line, which has no request to derive it from.
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/// Mints a link for a user, retiring any they already had.
62///
63/// Private, because everything public here has to decide *whether* to mint first, and a
64/// mint with no such decision in front of it is the mistake this shape prevents.
65///
66/// Old links go first: two live links means a stale mail in an inbox is still a way in,
67/// and the person asking for a second one is usually saying the first did not arrive.
68async 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/// Asks for a reset link by email, and mails it if there is anywhere to mail.
84///
85/// **Answers `Ok(())` whether or not the address matched anything**, and takes comparable
86/// time doing it: *both* paths pay for an Argon2 verification against
87/// [`ABSENT_USER_HASH`], so the two are not separated by the couple of hundred
88/// milliseconds a hash costs.
89///
90/// Both, not just the miss — which is the correction a stopwatch forced. Unlike
91/// [`login`](super::login::login), this use case has no password to check, so hashing
92/// only on the miss made the *hit* the fast one: measured against a real instance, a
93/// known address answered in 7 ms and an unknown one in 234 ms, and "fast means that
94/// address exists" leaks exactly as loudly as the reverse would.
95///
96/// The residual signal is a hit's SMTP round trip, which a miss does not pay. Closing
97/// that would mean queueing the send, and a queue is a bigger thing than this milestone
98/// has; the defence that actually matters is the identical response and the rate limit
99/// in front of the route.
100///
101/// A send that fails is logged and swallowed for the same reason: telling the visitor
102/// the mail bounced tells them the address exists.
103///
104/// Mints nothing when the instance cannot send email — a token nobody can be told about
105/// is a row and a risk with no benefit. The page renders the command-line instructions
106/// instead, so this path is not reachable from a browser that has been shown the truth.
107pub 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 // A malformed address cannot match anyone, but still costs the same work.
119 Err(_) => None,
120 };
121
122 // Unconditional, and before the branch: this is the cost that has to be the same on
123 // both sides, and putting it in one arm is what made them differ.
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 // No address, no link, no token — just that it did not go. Whoever is reading
137 // the log has the relay's own log beside it.
138 eprintln!("steid: a password reset mail could not be sent: {error}");
139 }
140
141 Ok(())
142}
143
144/// The mail itself.
145///
146/// Plain text, and deliberately says nothing the recipient does not already know: no
147/// handle, no address, no mention of who asked. Someone reading a stranger's inbox
148/// learns only that this instance exists, which the link says anyway.
149fn 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/// Mints a link for a handle, for an operator on the machine.
170///
171/// **No [`Actor`]**, like [`claim_instance`](super::claim::claim_instance) and for the
172/// same reason: the authority being exercised is possession of the box, not membership
173/// of the instance. Whoever can run this can already read the database.
174///
175/// `None` for a handle nobody holds, and for an organisation with no personal user
176/// behind it. The caller prints nothing in that case — see `main`, which explains why
177/// that is not the enumeration hole it looks like.
178pub 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/// Resolves a token from a URL into the reset it names, if it is still spendable.
201///
202/// [`DomainError::NotFound`] covers unknown, expired and already-spent alike: the form
203/// is shown or it is not, and which of the three it was is a fact only someone holding
204/// the token can act on — by asking for another, which the page offers either way.
205pub 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/// Spends a link and sets a new password.
225///
226/// Everything happens in one order that survives a crash between any two steps: the
227/// password lands first, then the token is spent, then the sessions go. A crash after
228/// the first leaves a working new password and a link that can be used again within its
229/// window, which is recoverable; the other orders leave a person locked out with a dead
230/// link.
231///
232/// **Every session of that user ends, including the one asking.** A reset is the path
233/// taken by someone who may have been locked out by whoever else holds the account, so
234/// there is no session worth keeping — signing in again with the new password is the
235/// proof it worked.
236#[allow(clippy::too_many_arguments)]
237pub 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 // Checked before the token is spent, so a mistyped confirmation does not cost the
250 // link — the form re-renders and the same link still works.
251 let hash = validated(new_password, confirmation, hasher)?;
252
253 let Some(mut user) = users.find_by_id(&reset.user_id).await? else {
254 // The account went between the link being issued and it being spent. Nothing to
255 // set a password on, and nothing worth distinguishing from a dead link.
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/// Changes the password of the actor's own account.
272///
273/// The current password is required: a borrowed unlocked laptop should not be a way to
274/// take the account permanently, which is exactly what changing the password without
275/// proving you know it would be.
276///
277/// **Every other session ends, and every outstanding reset link is retired.** `here` is
278/// the session doing the changing, kept so the person is not signed out of the browser
279/// they are looking at. Passing `None` ends that one too, which is what a caller with no
280/// session — there is none today — would want.
281///
282/// There is no "change someone else's password": an actor may only change their own, so
283/// there is no target to authorize against and no way to spell the abuse.
284#[allow(clippy::too_many_arguments)]
285pub 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 // A link minted before the password changed is still a way in until it expires.
313 resets.invalidate_for_user(&user.id, now).await?;
314 sessions.delete_by_user(&user.id, here).await?;
315
316 Ok(())
317}
318
319/// The rules a new password has to satisfy, in one place so both ways of setting one
320/// agree.
321///
322/// Two rules, and only one of them lives here: it must be typed twice the same, which is
323/// a property of the form rather than of the password. The password itself is
324/// [`Password::new`]'s business, and `claim_instance` asks the same question — so a
325/// password that can be set at setup can also be changed to, which was not true before.
326///
327/// The mismatch is checked first. Telling someone their two entries differ is more useful
328/// than telling them the first one was too short when they may have meant to type
329/// something else entirely.
330fn 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)]
345mod 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 /// The token out of the one mail sent, which is all a person ever has.
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 // --- asking for a link --------------------------------------------------------
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 // The whole no-enumeration rule, in one assertion: same `Ok(())`, nothing for
488 // the caller to branch on.
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 // A send failure must not become a way to tell a hit from a miss.
551 let f = Fixture::new().await;
552 let broken = RecordingMailer::unconfigured();
553
554 // `configured()` is false, so nothing is minted -- which is the same shape a
555 // miss has. The assertion that matters is that it is still `Ok`.
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 // --- spending a link ----------------------------------------------------------
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 // Empty is only the extreme case of too short. The link surviving matters: a
741 // rejected password must not cost someone their one-use link.
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 // Both are wrong here. Being told the two entries differ is the more useful of
817 // the two answers, and the pages' refusal codes depend on this order.
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 // --- the command line ---------------------------------------------------------
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 // --- changing a password you know ---------------------------------------------
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 // Otherwise a link asked for a minute ago is still a way in for half an hour.
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}