mod pages;
use std::{any::TypeId, future::Future, marker::PhantomData, time::Duration};
use topcoat::{
context::Cx,
router::{Body, RouteFuture, error::see_other, response::IntoResponse},
};
use self::pages::{ForgotAlert, forgot_response, invalid_link, reset_response};
use super::{
LoginThrottle, PanelUser, infrastructure_failure,
login::login_url,
mail::{TOKEN_FIELD, after_response, token_from_query},
registration::new_password_errors,
session::{credential_panels, end_credentials},
token::{self, REVOCATION, TokenPurpose, run_serializable},
};
use crate::{
BoxFuture,
auth::realm::{Realm, current_realm, hosted, panel_prefix},
flash::set_notification,
form::FieldErrors,
outcome::Notification,
};
pub const RESET_LIFETIME: Duration = Duration::from_hours(1);
pub trait PasswordResetter: Send + Sync + 'static {
type User: PanelUser;
fn find_by_email(
&self,
cx: &Cx,
email: &str,
) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
fn reset_password(
&self,
cx: &Cx,
user_id: &str,
password: &str,
ex: &mut dyn toasty::Executor,
) -> impl Future<Output = topcoat::Result<()>> + Send;
}
pub(crate) struct Recipient {
address: String,
user_id: String,
locale: Option<topcoat::l10n::Locale>,
}
pub(crate) trait DynPasswordResetter: Send + Sync {
fn recipient<'a>(
&'a self,
cx: &'a Cx,
email: &'a str,
) -> BoxFuture<'a, topcoat::Result<Option<Recipient>>>;
fn reset_password<'a>(
&'a self,
cx: &'a Cx,
user_id: &'a str,
password: &'a str,
ex: &'a mut dyn toasty::Executor,
) -> BoxFuture<'a, topcoat::Result<()>>;
fn user_type(&self) -> TypeId;
}
impl<R: PasswordResetter> DynPasswordResetter for R {
fn recipient<'a>(
&'a self,
cx: &'a Cx,
email: &'a str,
) -> BoxFuture<'a, topcoat::Result<Option<Recipient>>> {
Box::pin(async move {
let user = self.find_by_email(cx, email).await?;
let Some(user) = user.filter(PanelUser::can_access_panel) else {
return Ok(None);
};
let Some(email) = user.email() else {
tracing::warn!(
user = std::any::type_name::<R::User>(),
"a password reset found an account whose `PanelUser::email` is `None`"
);
return Ok(None);
};
Ok(Some(Recipient {
address: email.to_string(),
user_id: user.user_id(),
locale: user.locale(),
}))
})
}
fn reset_password<'a>(
&'a self,
cx: &'a Cx,
user_id: &'a str,
password: &'a str,
ex: &'a mut dyn toasty::Executor,
) -> BoxFuture<'a, topcoat::Result<()>> {
Box::pin(PasswordResetter::reset_password(
self, cx, user_id, password, ex,
))
}
fn user_type(&self) -> TypeId {
TypeId::of::<R::User>()
}
}
pub struct PasswordReset<U> {
resetter: Box<dyn DynPasswordResetter>,
throttle: LoginThrottle,
user: PhantomData<fn() -> U>,
}
impl<U: PanelUser> PasswordReset<U> {
#[must_use]
pub fn new(resetter: impl PasswordResetter<User = U>) -> Self {
Self {
resetter: Box::new(resetter),
throttle: LoginThrottle::new(3, Duration::from_mins(15)),
user: PhantomData,
}
}
#[must_use]
pub fn throttle(mut self, throttle: LoginThrottle) -> Self {
self.throttle = throttle;
self
}
pub(crate) fn erase(self) -> PasswordReset<()> {
PasswordReset {
resetter: self.resetter,
throttle: self.throttle,
user: PhantomData,
}
}
}
impl PasswordReset<()> {
pub(crate) fn resetter(&self) -> &dyn DynPasswordResetter {
&*self.resetter
}
}
impl<U> std::fmt::Debug for PasswordReset<U> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PasswordReset")
.field("throttle", &self.throttle)
.finish_non_exhaustive()
}
}
const EMAIL_FIELD: &str = "email";
const PASSWORD_FIELD: &str = "password";
const CONFIRMATION_FIELD: &str = "password_confirmation";
pub(crate) fn forgot_url(cx: &Cx) -> String {
format!("{}/forgot-password", panel_prefix(cx))
}
pub(crate) fn reset_url(cx: &Cx) -> String {
format!("{}/reset-password", panel_prefix(cx))
}
pub(crate) fn offered(cx: &Cx) -> bool {
current_realm(cx).is_some_and(|panel| panel.auth.reset().is_some())
}
pub(super) fn forgot_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
Box::pin(async move { forgot_response(cx, "", &FieldErrors::new(), None).await })
}
pub(super) fn forgot_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
let reset = panel
.auth
.reset()
.ok_or_else(topcoat::router::error::not_found)?;
let form = crate::auth::input::read_form(cx, body).await?;
crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
let email = form.last(EMAIL_FIELD).map_or("", str::trim);
if email.is_empty() {
let mut errors = FieldErrors::new();
errors.add(EMAIL_FIELD, crate::text!("tablo-reset-enter-email"));
return forgot_response(cx, email, &errors, None).await;
}
if !reset.throttle.attempt(email, None) {
tracing::warn!(panel = %panel.prefix, "password reset request throttled");
return forgot_response(cx, email, &FieldErrors::new(), Some(ForgotAlert::Throttled))
.await;
}
let recipient = match reset.resetter().recipient(cx, email).await {
Ok(recipient) => recipient,
Err(error) => {
let error = infrastructure_failure(error);
if crate::error::TabloError::is_infrastructure(&error) {
return forgot_response(
cx,
email,
&FieldErrors::new(),
Some(ForgotAlert::Unavailable),
)
.await;
}
return Err(error);
}
};
if let Some(recipient) = recipient {
after_response(cx, "password reset link", move |cx, panel| async move {
mail_link(&cx, &panel, &recipient).await
});
}
set_notification(
cx,
Notification::success(crate::t!(cx, "tablo-reset-sent"))
.description(crate::t!(cx, "tablo-reset-sent-description")),
);
see_other(login_url(cx)).into_response(cx)
})
}
async fn mail_link(cx: &Cx, panel: &Realm, recipient: &Recipient) -> topcoat::Result<()> {
let Recipient {
address,
user_id,
locale,
} = recipient;
let cx = &crate::i18n::addressed_to(cx, locale.as_ref());
let mail = panel
.auth
.auth_mail()
.ok_or_else(topcoat::router::error::not_found)?;
let token = token::issue_alone(
cx,
TokenPurpose::PasswordReset,
user_id,
None,
RESET_LIFETIME,
)
.await?;
let link = mail.link(&reset_url(cx), &token);
let brand = hosted(cx)?.brand_name(cx);
let minutes = RESET_LIFETIME.as_secs() / 60;
let message = mail.compose(
address,
crate::t!(cx, "tablo-reset-mail-subject", brand = brand.clone()),
crate::t!(
cx,
"tablo-reset-mail-body",
brand = brand,
minutes = minutes,
link = link
),
)?;
topcoat::mail::send(cx, message).await?;
Ok(())
}
async fn live_token(
cx: &Cx,
ex: &mut dyn toasty::Executor,
encoded: &str,
) -> topcoat::Result<Option<token::AuthToken>> {
token::live(cx, ex, TokenPurpose::PasswordReset, encoded).await
}
pub(super) fn reset_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let token = token_from_query(cx);
let mut db = crate::db::db(cx)?;
let live = live_token(cx, &mut db, &token).await?;
if live.is_none() {
return invalid_link(cx).await;
}
reset_response(cx, &token, &FieldErrors::new()).await
})
}
pub(super) fn reset_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
let reset = panel
.auth
.reset()
.ok_or_else(topcoat::router::error::not_found)?;
let form = crate::auth::input::read_form(cx, body).await?;
crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
let token = form.last(TOKEN_FIELD).unwrap_or_default().to_string();
let password = form.last(PASSWORD_FIELD).unwrap_or_default();
let confirmation = form.last(CONFIRMATION_FIELD).unwrap_or_default();
let mut db = crate::db::db(cx)?;
let Some(row) = live_token(cx, &mut db, &token).await? else {
return invalid_link(cx).await;
};
if !may_enter(cx, panel, &row.user_id).await? {
return invalid_link(cx).await;
}
let errors = new_password_errors(password, confirmation);
if !errors.is_empty() {
return reset_response(cx, &token, &errors).await;
}
let reset_done = run_serializable!(cx, |tx| async {
if live_token(cx, tx, &token).await?.is_none() {
return Ok(false);
}
reset
.resetter()
.reset_password(cx, &row.user_id, password, tx)
.await
.map_err(infrastructure_failure)?;
let user_type = reset.resetter().user_type();
let panels = credential_panels(cx, user_type, panel);
end_credentials(tx, &row.user_id, &panels, REVOCATION).await?;
Ok(true)
})?;
if !reset_done {
return invalid_link(cx).await;
}
set_notification(
cx,
Notification::success(crate::t!(cx, "tablo-reset-done"))
.description(crate::t!(cx, "tablo-reset-done-description")),
);
see_other(login_url(cx)).into_response(cx)
})
}
async fn may_enter(cx: &Cx, panel: &Realm, user_id: &str) -> topcoat::Result<bool> {
let Some(authenticator) = panel.auth.authenticator() else {
return Ok(false);
};
let user = authenticator
.find_by_id(cx, user_id)
.await
.map_err(infrastructure_failure)?;
Ok(user.is_some_and(|user| user.can_access_panel()))
}
pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
use crate::{DeclarationErrorKind, declaration::ResetFault};
if auth.reset.is_some() && auth.authenticator().is_none() {
return vec![DeclarationErrorKind::PasswordReset(
ResetFault::AuthDisabled,
)];
}
Vec::new()
}