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 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.throttle.attempt(email, None) {
310 tracing::warn!(panel = %panel.prefix, "password reset request throttled");
311 return forgot_response(cx, email, &FieldErrors::new(), Some(ForgotAlert::Throttled))
312 .await;
313 }
314 let recipient = match reset.resetter().recipient(cx, &panel.prefix, email).await {
315 Ok(recipient) => recipient,
316 Err(error) => {
317 let error = infrastructure_failure(error);
318 if crate::error::TabloError::is_infrastructure(&error) {
319 return forgot_response(
320 cx,
321 email,
322 &FieldErrors::new(),
323 Some(ForgotAlert::Unavailable),
324 )
325 .await;
326 }
327 return Err(error);
328 }
329 };
330 if let Some(recipient) = recipient {
331 after_response(cx, "password reset link", move |cx, panel| async move {
332 mail_link(&cx, &panel, &recipient).await
333 });
334 }
335 set_notification(
336 cx,
337 Notification::success(crate::t!(cx, "tablo-reset-sent"))
338 .description(crate::t!(cx, "tablo-reset-sent-description")),
339 );
340 see_other(login_url(cx)).into_response(cx)
341 })
342}
343
344async fn mail_link(cx: &Cx, panel: &Realm, recipient: &Recipient) -> topcoat::Result<()> {
347 let Recipient {
348 address,
349 user_id,
350 locale,
351 } = recipient;
352 let cx = &crate::i18n::addressed_to(cx, locale.as_ref());
353 let mail = panel
354 .auth
355 .auth_mail()
356 .ok_or_else(topcoat::router::error::not_found)?;
357 let token = token::issue_alone(
358 cx,
359 TokenPurpose::PasswordReset,
360 user_id,
361 None,
362 RESET_LIFETIME,
363 )
364 .await?;
365 let link = mail.link(&reset_url(cx), &token);
366 let brand = hosted(cx)?.brand_name(cx);
367 let minutes = RESET_LIFETIME.as_secs() / 60;
368 let message = mail.compose(
369 address,
370 crate::t!(cx, "tablo-reset-mail-subject", brand = brand.clone()),
371 crate::t!(
372 cx,
373 "tablo-reset-mail-body",
374 brand = brand,
375 minutes = minutes,
376 link = link
377 ),
378 )?;
379 topcoat::mail::send(cx, message).await?;
380 Ok(())
381}
382
383async fn live_token(
385 cx: &Cx,
386 ex: &mut dyn toasty::Executor,
387 encoded: &str,
388) -> topcoat::Result<Option<token::AuthToken>> {
389 token::live(cx, ex, TokenPurpose::PasswordReset, encoded).await
390}
391
392pub(super) fn reset_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
395 Box::pin(async move {
396 let token = token_from_query(cx);
397 let mut db = crate::db::db(cx)?;
398 let live = live_token(cx, &mut db, &token).await?;
399 if live.is_none() {
400 return invalid_link(cx).await;
401 }
402 reset_response(cx, &token, &FieldErrors::new()).await
403 })
404}
405
406pub(super) fn reset_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
409 Box::pin(async move {
410 let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
411 let reset = panel
412 .auth
413 .reset()
414 .ok_or_else(topcoat::router::error::not_found)?;
415 let form = crate::auth::input::read_form(cx, body).await?;
416 crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
417 let token = form.last(TOKEN_FIELD).unwrap_or_default().to_string();
418 let password = form.last(PASSWORD_FIELD).unwrap_or_default();
419 let confirmation = form.last(CONFIRMATION_FIELD).unwrap_or_default();
420 let mut db = crate::db::db(cx)?;
421 let Some(row) = live_token(cx, &mut db, &token).await? else {
422 return invalid_link(cx).await;
423 };
424 if !may_enter(cx, panel, &row.user_id).await? {
427 return invalid_link(cx).await;
428 }
429 let errors = new_password_errors(password, confirmation);
430 if !errors.is_empty() {
431 return reset_response(cx, &token, &errors).await;
432 }
433 let reset_done = run_serializable!(cx, |tx| async {
434 if live_token(cx, tx, &token).await?.is_none() {
436 return Ok(false);
437 }
438 reset
439 .resetter()
440 .reset_password(cx, &row.user_id, password, tx)
441 .await
442 .map_err(infrastructure_failure)?;
443 let user_type = reset.resetter().user_type();
444 let panels = credential_panels(cx, user_type, panel);
445 end_credentials(tx, &row.user_id, &panels, REVOCATION).await?;
446 Ok(true)
447 })?;
448 if !reset_done {
449 return invalid_link(cx).await;
450 }
451 set_notification(
452 cx,
453 Notification::success(crate::t!(cx, "tablo-reset-done"))
454 .description(crate::t!(cx, "tablo-reset-done-description")),
455 );
456 see_other(login_url(cx)).into_response(cx)
457 })
458}
459
460async fn may_enter(cx: &Cx, panel: &Realm, user_id: &str) -> topcoat::Result<bool> {
463 let Some(authenticator) = panel.auth.authenticator() else {
464 return Ok(false);
465 };
466 let user = authenticator
467 .find_by_id(cx, user_id)
468 .await
469 .map_err(infrastructure_failure)?;
470 Ok(user.is_some_and(|user| user.can_access_panel(&panel.prefix)))
471}
472
473pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
477 use crate::{DeclarationErrorKind, declaration::ResetFault};
478
479 if auth.reset.is_some() && auth.authenticator().is_none() {
480 return vec![DeclarationErrorKind::PasswordReset(
481 ResetFault::AuthDisabled,
482 )];
483 }
484 Vec::new()
485}