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 {
89 type User: PanelUser;
92
93 fn find_by_email(
96 &self,
97 cx: &Cx,
98 email: &str,
99 ) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
100
101 fn reset_password(
108 &self,
109 cx: &Cx,
110 user_id: &str,
111 password: &str,
112 ex: &mut dyn toasty::Executor,
113 ) -> impl Future<Output = topcoat::Result<()>> + Send;
114}
115
116pub(crate) struct Recipient {
118 address: String,
119 user_id: String,
120 locale: Option<topcoat::l10n::Locale>,
122}
123
124pub(crate) trait DynPasswordResetter: Send + Sync {
126 fn recipient<'a>(
129 &'a self,
130 cx: &'a Cx,
131 email: &'a str,
132 ) -> BoxFuture<'a, topcoat::Result<Option<Recipient>>>;
133 fn reset_password<'a>(
134 &'a self,
135 cx: &'a Cx,
136 user_id: &'a str,
137 password: &'a str,
138 ex: &'a mut dyn toasty::Executor,
139 ) -> BoxFuture<'a, topcoat::Result<()>>;
140 fn user_type(&self) -> TypeId;
143}
144
145impl<R: PasswordResetter> DynPasswordResetter for R {
146 fn recipient<'a>(
147 &'a self,
148 cx: &'a Cx,
149 email: &'a str,
150 ) -> BoxFuture<'a, topcoat::Result<Option<Recipient>>> {
151 Box::pin(async move {
152 let user = self.find_by_email(cx, email).await?;
153 let Some(user) = user.filter(PanelUser::can_access_panel) else {
154 return Ok(None);
155 };
156 let Some(email) = user.email() else {
157 tracing::warn!(
159 user = std::any::type_name::<R::User>(),
160 "a password reset found an account whose `PanelUser::email` is `None`"
161 );
162 return Ok(None);
163 };
164 Ok(Some(Recipient {
165 address: email.to_string(),
166 user_id: user.user_id(),
167 locale: user.locale(),
168 }))
169 })
170 }
171
172 fn reset_password<'a>(
173 &'a self,
174 cx: &'a Cx,
175 user_id: &'a str,
176 password: &'a str,
177 ex: &'a mut dyn toasty::Executor,
178 ) -> BoxFuture<'a, topcoat::Result<()>> {
179 Box::pin(PasswordResetter::reset_password(
180 self, cx, user_id, password, ex,
181 ))
182 }
183
184 fn user_type(&self) -> TypeId {
185 TypeId::of::<R::User>()
186 }
187}
188
189pub struct PasswordReset<U> {
213 resetter: Box<dyn DynPasswordResetter>,
214 throttle: LoginThrottle,
215 user: PhantomData<fn() -> U>,
216}
217
218impl<U: PanelUser> PasswordReset<U> {
219 #[must_use]
221 pub fn new(resetter: impl PasswordResetter<User = U>) -> Self {
222 Self {
223 resetter: Box::new(resetter),
224 throttle: LoginThrottle::new(3, Duration::from_mins(15)),
225 user: PhantomData,
226 }
227 }
228
229 #[must_use]
233 pub fn throttle(mut self, throttle: LoginThrottle) -> Self {
234 self.throttle = throttle;
235 self
236 }
237
238 pub(crate) fn erase(self) -> PasswordReset<()> {
240 PasswordReset {
241 resetter: self.resetter,
242 throttle: self.throttle,
243 user: PhantomData,
244 }
245 }
246}
247
248impl PasswordReset<()> {
249 pub(crate) fn resetter(&self) -> &dyn DynPasswordResetter {
250 &*self.resetter
251 }
252}
253
254impl<U> std::fmt::Debug for PasswordReset<U> {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 f.debug_struct("PasswordReset")
257 .field("throttle", &self.throttle)
258 .finish_non_exhaustive()
259 }
260}
261
262const EMAIL_FIELD: &str = "email";
264const PASSWORD_FIELD: &str = "password";
267const CONFIRMATION_FIELD: &str = "password_confirmation";
268
269pub(crate) fn forgot_url(cx: &Cx) -> String {
271 format!("{}/forgot-password", panel_prefix(cx))
272}
273
274pub(crate) fn reset_url(cx: &Cx) -> String {
276 format!("{}/reset-password", panel_prefix(cx))
277}
278
279pub(crate) fn offered(cx: &Cx) -> bool {
281 current_realm(cx).is_some_and(|panel| panel.auth.reset().is_some())
282}
283
284pub(super) fn forgot_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
286 Box::pin(async move { forgot_response(cx, "", &FieldErrors::new(), None).await })
287}
288
289pub(super) fn forgot_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
292 Box::pin(async move {
293 let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
294 let reset = panel
295 .auth
296 .reset()
297 .ok_or_else(topcoat::router::error::not_found)?;
298 let form = crate::auth::input::read_form(cx, body).await?;
299 crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
300 let email = form.last(EMAIL_FIELD).map_or("", str::trim);
301 if email.is_empty() {
302 let mut errors = FieldErrors::new();
303 errors.add(EMAIL_FIELD, crate::text!("tablo-reset-enter-email"));
304 return forgot_response(cx, email, &errors, None).await;
305 }
306 if !reset.throttle.attempt(email, None) {
307 tracing::warn!(panel = %panel.prefix, "password reset request throttled");
308 return forgot_response(cx, email, &FieldErrors::new(), Some(ForgotAlert::Throttled))
309 .await;
310 }
311 let recipient = match reset.resetter().recipient(cx, email).await {
312 Ok(recipient) => recipient,
313 Err(error) => {
314 let error = infrastructure_failure(error);
315 if crate::error::TabloError::is_infrastructure(&error) {
316 return forgot_response(
317 cx,
318 email,
319 &FieldErrors::new(),
320 Some(ForgotAlert::Unavailable),
321 )
322 .await;
323 }
324 return Err(error);
325 }
326 };
327 if let Some(recipient) = recipient {
328 after_response(cx, "password reset link", move |cx, panel| async move {
329 mail_link(&cx, &panel, &recipient).await
330 });
331 }
332 set_notification(
333 cx,
334 Notification::success(crate::t!(cx, "tablo-reset-sent"))
335 .description(crate::t!(cx, "tablo-reset-sent-description")),
336 );
337 see_other(login_url(cx)).into_response(cx)
338 })
339}
340
341async fn mail_link(cx: &Cx, panel: &Realm, recipient: &Recipient) -> topcoat::Result<()> {
344 let Recipient {
345 address,
346 user_id,
347 locale,
348 } = recipient;
349 let cx = &crate::i18n::addressed_to(cx, locale.as_ref());
350 let mail = panel
351 .auth
352 .auth_mail()
353 .ok_or_else(topcoat::router::error::not_found)?;
354 let token = token::issue_alone(
355 cx,
356 TokenPurpose::PasswordReset,
357 user_id,
358 None,
359 RESET_LIFETIME,
360 )
361 .await?;
362 let link = mail.link(&reset_url(cx), &token);
363 let brand = hosted(cx)?.brand_name(cx);
364 let minutes = RESET_LIFETIME.as_secs() / 60;
365 let message = mail.compose(
366 address,
367 crate::t!(cx, "tablo-reset-mail-subject", brand = brand.clone()),
368 crate::t!(
369 cx,
370 "tablo-reset-mail-body",
371 brand = brand,
372 minutes = minutes,
373 link = link
374 ),
375 )?;
376 topcoat::mail::send(cx, message).await?;
377 Ok(())
378}
379
380async fn live_token(
382 cx: &Cx,
383 ex: &mut dyn toasty::Executor,
384 encoded: &str,
385) -> topcoat::Result<Option<token::AuthToken>> {
386 token::live(cx, ex, TokenPurpose::PasswordReset, encoded).await
387}
388
389pub(super) fn reset_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
392 Box::pin(async move {
393 let token = token_from_query(cx);
394 let mut db = crate::db::db(cx)?;
395 let live = live_token(cx, &mut db, &token).await?;
396 if live.is_none() {
397 return invalid_link(cx).await;
398 }
399 reset_response(cx, &token, &FieldErrors::new()).await
400 })
401}
402
403pub(super) fn reset_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
406 Box::pin(async move {
407 let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
408 let reset = panel
409 .auth
410 .reset()
411 .ok_or_else(topcoat::router::error::not_found)?;
412 let form = crate::auth::input::read_form(cx, body).await?;
413 crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
414 let token = form.last(TOKEN_FIELD).unwrap_or_default().to_string();
415 let password = form.last(PASSWORD_FIELD).unwrap_or_default();
416 let confirmation = form.last(CONFIRMATION_FIELD).unwrap_or_default();
417 let mut db = crate::db::db(cx)?;
418 let Some(row) = live_token(cx, &mut db, &token).await? else {
419 return invalid_link(cx).await;
420 };
421 if !may_enter(cx, panel, &row.user_id).await? {
424 return invalid_link(cx).await;
425 }
426 let errors = new_password_errors(password, confirmation);
427 if !errors.is_empty() {
428 return reset_response(cx, &token, &errors).await;
429 }
430 let reset_done = run_serializable!(cx, |tx| async {
431 if live_token(cx, tx, &token).await?.is_none() {
433 return Ok(false);
434 }
435 reset
436 .resetter()
437 .reset_password(cx, &row.user_id, password, tx)
438 .await
439 .map_err(infrastructure_failure)?;
440 let user_type = reset.resetter().user_type();
441 let panels = credential_panels(cx, user_type, panel);
442 end_credentials(tx, &row.user_id, &panels, REVOCATION).await?;
443 Ok(true)
444 })?;
445 if !reset_done {
446 return invalid_link(cx).await;
447 }
448 set_notification(
449 cx,
450 Notification::success(crate::t!(cx, "tablo-reset-done"))
451 .description(crate::t!(cx, "tablo-reset-done-description")),
452 );
453 see_other(login_url(cx)).into_response(cx)
454 })
455}
456
457async fn may_enter(cx: &Cx, panel: &Realm, user_id: &str) -> topcoat::Result<bool> {
460 let Some(authenticator) = panel.auth.authenticator() else {
461 return Ok(false);
462 };
463 let user = authenticator
464 .find_by_id(cx, user_id)
465 .await
466 .map_err(infrastructure_failure)?;
467 Ok(user.is_some_and(|user| user.can_access_panel()))
468}
469
470pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
474 use crate::{DeclarationErrorKind, declaration::ResetFault};
475
476 if auth.reset.is_some() && auth.authenticator().is_none() {
477 return vec![DeclarationErrorKind::PasswordReset(
478 ResetFault::AuthDisabled,
479 )];
480 }
481 Vec::new()
482}