1mod pages;
6
7use std::{any::TypeId, future::Future, marker::PhantomData, time::Duration};
8
9use topcoat::{
10 context::Cx,
11 router::{Body, RouteFuture, error::see_other, response::IntoResponse},
12};
13
14use self::pages::{ForgotAlert, forgot_response, invalid_link, reset_response};
15use super::{
16 LoginThrottle, PanelUser, infrastructure_failure,
17 login::login_url,
18 mail::{TOKEN_FIELD, after_response, token_from_query},
19 registration::new_password_errors,
20 session::{credential_panels, end_credentials},
21 token::{self, REVOCATION, TokenPurpose, run_serializable},
22};
23use crate::{
24 BoxFuture,
25 auth::realm::{Realm, current_realm, hosted, panel_prefix},
26 flash::set_notification,
27 form::FieldErrors,
28 outcome::Notification,
29};
30
31pub const RESET_LIFETIME: Duration = Duration::from_hours(1);
33
34pub trait PasswordResetter: Send + Sync + 'static {
90 type User: PanelUser;
93
94 fn find_by_email(
97 &self,
98 cx: &Cx,
99 email: &str,
100 ) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
101
102 fn reset_password(
109 &self,
110 cx: &Cx,
111 user_id: &str,
112 password: &str,
113 ex: &mut dyn toasty::Executor,
114 ) -> impl Future<Output = topcoat::Result<()>> + Send;
115}
116
117pub(crate) struct Recipient {
119 address: String,
120 user_id: String,
121 locale: Option<topcoat::l10n::Locale>,
123}
124
125pub(crate) trait DynPasswordResetter: Send + Sync {
127 fn recipient<'a>(
130 &'a self,
131 cx: &'a Cx,
132 panel: &'a str,
133 email: &'a str,
134 ) -> BoxFuture<'a, topcoat::Result<Option<Recipient>>>;
135 fn reset_password<'a>(
136 &'a self,
137 cx: &'a Cx,
138 user_id: &'a str,
139 password: &'a str,
140 ex: &'a mut dyn toasty::Executor,
141 ) -> BoxFuture<'a, topcoat::Result<()>>;
142 fn user_type(&self) -> TypeId;
145}
146
147impl<R: PasswordResetter> DynPasswordResetter for R {
148 fn recipient<'a>(
149 &'a self,
150 cx: &'a Cx,
151 panel: &'a str,
152 email: &'a str,
153 ) -> BoxFuture<'a, topcoat::Result<Option<Recipient>>> {
154 Box::pin(async move {
155 let user = self.find_by_email(cx, email).await?;
156 let Some(user) = user.filter(|user| user.can_access_panel(panel)) else {
157 return Ok(None);
158 };
159 let Some(email) = user.email() else {
160 tracing::warn!(
162 user = std::any::type_name::<R::User>(),
163 "a password reset found an account whose `PanelUser::email` is `None`"
164 );
165 return Ok(None);
166 };
167 Ok(Some(Recipient {
168 address: email.to_string(),
169 user_id: user.user_id(),
170 locale: user.locale(),
171 }))
172 })
173 }
174
175 fn reset_password<'a>(
176 &'a self,
177 cx: &'a Cx,
178 user_id: &'a str,
179 password: &'a str,
180 ex: &'a mut dyn toasty::Executor,
181 ) -> BoxFuture<'a, topcoat::Result<()>> {
182 Box::pin(PasswordResetter::reset_password(
183 self, cx, user_id, password, ex,
184 ))
185 }
186
187 fn user_type(&self) -> TypeId {
188 TypeId::of::<R::User>()
189 }
190}
191
192pub struct PasswordReset<U> {
216 resetter: Box<dyn DynPasswordResetter>,
217 pub(in crate::auth) throttle: LoginThrottle,
218 user: PhantomData<fn() -> U>,
219}
220
221impl<U: PanelUser> PasswordReset<U> {
222 #[must_use]
224 pub fn new(resetter: impl PasswordResetter<User = U>) -> Self {
225 Self {
226 resetter: Box::new(resetter),
227 throttle: LoginThrottle::new(3, Duration::from_mins(15)),
228 user: PhantomData,
229 }
230 }
231
232 #[must_use]
236 pub fn throttle(mut self, throttle: LoginThrottle) -> Self {
237 self.throttle = throttle;
238 self
239 }
240
241 pub(crate) fn erase(self) -> PasswordReset<()> {
243 PasswordReset {
244 resetter: self.resetter,
245 throttle: self.throttle,
246 user: PhantomData,
247 }
248 }
249}
250
251impl PasswordReset<()> {
252 pub(crate) fn resetter(&self) -> &dyn DynPasswordResetter {
253 &*self.resetter
254 }
255}
256
257impl<U> std::fmt::Debug for PasswordReset<U> {
258 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259 f.debug_struct("PasswordReset")
260 .field("throttle", &self.throttle)
261 .finish_non_exhaustive()
262 }
263}
264
265const EMAIL_FIELD: &str = "email";
267const PASSWORD_FIELD: &str = "password";
270const CONFIRMATION_FIELD: &str = "password_confirmation";
271
272pub(crate) fn forgot_url(cx: &Cx) -> String {
274 format!("{}/forgot-password", panel_prefix(cx))
275}
276
277pub(crate) fn reset_url(cx: &Cx) -> String {
279 format!("{}/reset-password", panel_prefix(cx))
280}
281
282pub(crate) fn offered(cx: &Cx) -> bool {
284 current_realm(cx).is_some_and(|panel| panel.auth.reset().is_some())
285}
286
287pub(super) fn forgot_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
289 Box::pin(async move { forgot_response(cx, "", &FieldErrors::new(), None).await })
290}
291
292pub(super) fn forgot_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
295 Box::pin(async move {
296 let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
297 let reset = panel
298 .auth
299 .reset()
300 .ok_or_else(topcoat::router::error::not_found)?;
301 let form = crate::auth::input::read_form(cx, body).await?;
302 crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
303 let email = form.last(EMAIL_FIELD).map_or("", str::trim);
304 if email.is_empty() {
305 let mut errors = FieldErrors::new();
306 errors.add(EMAIL_FIELD, crate::text!("tablo-reset-enter-email"));
307 return forgot_response(cx, email, &errors, None).await;
308 }
309 if !reset
310 .throttle
311 .attempt(cx, "password-reset", email, None)
312 .await?
313 {
314 tracing::warn!(panel = %panel.prefix, "password reset request throttled");
315 return forgot_response(cx, email, &FieldErrors::new(), Some(ForgotAlert::Throttled))
316 .await;
317 }
318 let recipient = match reset.resetter().recipient(cx, &panel.prefix, email).await {
319 Ok(recipient) => recipient,
320 Err(error) => {
321 let error = infrastructure_failure(error);
322 if crate::error::TabloError::is_infrastructure(&error) {
323 return forgot_response(
324 cx,
325 email,
326 &FieldErrors::new(),
327 Some(ForgotAlert::Unavailable),
328 )
329 .await;
330 }
331 return Err(error);
332 }
333 };
334 if let Some(recipient) = recipient {
335 after_response(cx, "password reset link", move |cx, panel| async move {
336 mail_link(&cx, &panel, &recipient).await
337 });
338 }
339 set_notification(
340 cx,
341 Notification::success(crate::t!(cx, "tablo-reset-sent"))
342 .description(crate::t!(cx, "tablo-reset-sent-description")),
343 );
344 see_other(login_url(cx)).into_response(cx)
345 })
346}
347
348async fn mail_link(cx: &Cx, panel: &Realm, recipient: &Recipient) -> topcoat::Result<()> {
351 let Recipient {
352 address,
353 user_id,
354 locale,
355 } = recipient;
356 let cx = &crate::i18n::addressed_to(cx, locale.as_ref());
357 let mail = panel
358 .auth
359 .auth_mail()
360 .ok_or_else(topcoat::router::error::not_found)?;
361 let token = token::issue_alone(
362 cx,
363 TokenPurpose::PasswordReset,
364 user_id,
365 None,
366 RESET_LIFETIME,
367 )
368 .await?;
369 let link = mail.link(&reset_url(cx), &token);
370 let brand = hosted(cx)?.brand_name(cx);
371 let minutes = RESET_LIFETIME.as_secs() / 60;
372 let message = mail.compose(
373 address,
374 crate::t!(cx, "tablo-reset-mail-subject", brand = brand.clone()),
375 crate::t!(
376 cx,
377 "tablo-reset-mail-body",
378 brand = brand,
379 minutes = minutes,
380 link = link
381 ),
382 )?;
383 topcoat::mail::send(cx, message).await?;
384 Ok(())
385}
386
387async fn live_token(
389 cx: &Cx,
390 ex: &mut dyn toasty::Executor,
391 encoded: &str,
392) -> topcoat::Result<Option<token::AuthToken>> {
393 token::live(cx, ex, TokenPurpose::PasswordReset, encoded).await
394}
395
396pub(super) fn reset_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
399 Box::pin(async move {
400 let token = token_from_query(cx);
401 let mut db = crate::db::db(cx)?;
402 let live = live_token(cx, &mut db, &token).await?;
403 if live.is_none() {
404 return invalid_link(cx).await;
405 }
406 reset_response(cx, &token, &FieldErrors::new()).await
407 })
408}
409
410pub(super) fn reset_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
413 Box::pin(async move {
414 let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
415 let reset = panel
416 .auth
417 .reset()
418 .ok_or_else(topcoat::router::error::not_found)?;
419 let form = crate::auth::input::read_form(cx, body).await?;
420 crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
421 let token = form.last(TOKEN_FIELD).unwrap_or_default().to_string();
422 let password = form.last(PASSWORD_FIELD).unwrap_or_default();
423 let confirmation = form.last(CONFIRMATION_FIELD).unwrap_or_default();
424 let mut db = crate::db::db(cx)?;
425 let Some(row) = live_token(cx, &mut db, &token).await? else {
426 return invalid_link(cx).await;
427 };
428 if !may_enter(cx, panel, &row.user_id).await? {
431 return invalid_link(cx).await;
432 }
433 let errors = new_password_errors(password, confirmation);
434 if !errors.is_empty() {
435 return reset_response(cx, &token, &errors).await;
436 }
437 let reset_done = run_serializable!(cx, |tx| async {
438 if live_token(cx, tx, &token).await?.is_none() {
440 return Ok(false);
441 }
442 reset
443 .resetter()
444 .reset_password(cx, &row.user_id, password, tx)
445 .await
446 .map_err(infrastructure_failure)?;
447 let user_type = reset.resetter().user_type();
448 let panels = credential_panels(cx, user_type, panel);
449 end_credentials(tx, &row.user_id, &panels, REVOCATION).await?;
450 Ok(true)
451 })?;
452 if !reset_done {
453 return invalid_link(cx).await;
454 }
455 set_notification(
456 cx,
457 Notification::success(crate::t!(cx, "tablo-reset-done"))
458 .description(crate::t!(cx, "tablo-reset-done-description")),
459 );
460 see_other(login_url(cx)).into_response(cx)
461 })
462}
463
464async fn may_enter(cx: &Cx, panel: &Realm, user_id: &str) -> topcoat::Result<bool> {
467 let Some(authenticator) = panel.auth.authenticator() else {
468 return Ok(false);
469 };
470 let user = authenticator
471 .find_by_id(cx, user_id)
472 .await
473 .map_err(infrastructure_failure)?;
474 Ok(user.is_some_and(|user| user.can_access_panel(&panel.prefix)))
475}
476
477pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
481 use crate::{DeclarationErrorKind, declaration::ResetFault};
482
483 if auth.reset.is_some() && auth.authenticator().is_none() {
484 return vec![DeclarationErrorKind::PasswordReset(
485 ResetFault::AuthDisabled,
486 )];
487 }
488 Vec::new()
489}