mod pages;
use std::{
any::{Any, TypeId},
future::Future,
marker::PhantomData,
time::Duration,
};
use topcoat::{
context::Cx,
router::{
Body, RouteFuture,
error::{see_other, too_many_requests},
response::IntoResponse,
},
};
pub(super) use self::pages::invalid_link;
use self::pages::notice;
use super::{
LoginThrottle, PanelUser, infrastructure_failure,
login::login_url,
mail::{after_response, private, token_from_query},
panel_root, resolved,
session::resolve,
token::{self, TokenPurpose, run_serializable},
unauthenticated_error,
};
use crate::{
BoxFuture,
auth::realm::{Realm, current_realm, hosted, panel_prefix},
flash::set_notification,
outcome::Notification,
};
pub const VERIFICATION_LIFETIME: Duration = Duration::from_hours(24);
pub trait EmailVerifier: Send + Sync + 'static {
type User: PanelUser;
fn is_verified(&self, user: &Self::User) -> bool;
fn store_verified_email(
&self,
cx: &Cx,
user_id: &str,
email: &str,
ex: &mut dyn toasty::Executor,
) -> impl Future<Output = topcoat::Result<()>> + Send;
}
pub(crate) trait DynEmailVerifier: Send + Sync {
fn is_verified(&self, user: &dyn PanelUser) -> bool;
fn store_verified_email<'a>(
&'a self,
cx: &'a Cx,
user_id: &'a str,
email: &'a str,
ex: &'a mut dyn toasty::Executor,
) -> BoxFuture<'a, topcoat::Result<()>>;
fn user_type(&self) -> TypeId;
}
impl<V: EmailVerifier> DynEmailVerifier for V {
fn is_verified(&self, user: &dyn PanelUser) -> bool {
let user: &dyn Any = user;
user.downcast_ref::<V::User>()
.is_some_and(|user| EmailVerifier::is_verified(self, user))
}
fn store_verified_email<'a>(
&'a self,
cx: &'a Cx,
user_id: &'a str,
email: &'a str,
ex: &'a mut dyn toasty::Executor,
) -> BoxFuture<'a, topcoat::Result<()>> {
Box::pin(EmailVerifier::store_verified_email(
self, cx, user_id, email, ex,
))
}
fn user_type(&self) -> TypeId {
TypeId::of::<V::User>()
}
}
pub struct EmailVerification<U> {
verifier: Box<dyn DynEmailVerifier>,
pub(in crate::auth) throttle: LoginThrottle,
user: PhantomData<fn() -> U>,
}
impl<U: PanelUser> EmailVerification<U> {
#[must_use]
pub fn new(verifier: impl EmailVerifier<User = U>) -> Self {
Self {
verifier: Box::new(verifier),
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) -> EmailVerification<()> {
EmailVerification {
verifier: self.verifier,
throttle: self.throttle,
user: PhantomData,
}
}
}
impl EmailVerification<()> {
pub(crate) fn verifier(&self) -> &dyn DynEmailVerifier {
&*self.verifier
}
}
impl<U> std::fmt::Debug for EmailVerification<U> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EmailVerification")
.field("throttle", &self.throttle)
.finish_non_exhaustive()
}
}
pub(crate) fn notice_url(cx: &Cx) -> String {
format!("{}/verify-email", panel_prefix(cx))
}
pub(crate) fn confirm_url(cx: &Cx) -> String {
format!("{}/verify-email/confirm", panel_prefix(cx))
}
pub(crate) fn pending(panel: &Realm, user: &dyn PanelUser) -> bool {
user.email().is_some()
&& panel
.auth
.verification()
.is_some_and(|verification| !verification.verifier().is_verified(user))
}
pub(super) fn mail_after_sign_up(cx: &Cx, user: &dyn PanelUser) {
let Some(panel) = current_realm(cx) else {
return;
};
let Some(email) = user.email().filter(|_| pending(panel, user)) else {
return;
};
let (email, user_id) = (email.to_string(), user.user_id());
let locale = user.locale();
after_response(cx, "verification link", move |cx, panel| async move {
let cx = crate::i18n::addressed_to(&cx, locale.as_ref());
mail_link(&cx, &panel, &user_id, &email).await
});
}
async fn mail_link(cx: &Cx, panel: &Realm, user_id: &str, email: &str) -> topcoat::Result<()> {
let mail = panel
.auth
.auth_mail()
.ok_or_else(topcoat::router::error::not_found)?;
let token = token::issue_alone(
cx,
TokenPurpose::EmailVerification,
user_id,
Some(email.to_string()),
VERIFICATION_LIFETIME,
)
.await?;
let link = mail.link(&confirm_url(cx), &token);
let brand = hosted(cx)?.brand_name(cx);
let message = mail.compose(
email,
crate::t!(cx, "tablo-verify-mail-subject", brand = brand.clone()),
crate::t!(
cx,
"tablo-verify-mail-body",
email = email,
brand = brand,
link = link
),
)?;
topcoat::mail::send(cx, message).await?;
Ok(())
}
pub(super) fn notice_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let signed = resolved(cx).ok_or_else(|| unauthenticated_error(cx))?;
if !pending(&signed.realm, signed.user.as_ref()) {
return see_other(panel_root(cx)).into_response(cx);
}
notice(cx, signed.user.email().unwrap_or_default()).await
})
}
pub(super) fn notice_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let signed = resolved(cx).ok_or_else(|| unauthenticated_error(cx))?;
let verification = signed
.realm
.auth
.verification()
.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))?;
if !pending(&signed.realm, signed.user.as_ref()) {
return see_other(panel_root(cx)).into_response(cx);
}
let user_id = signed.user.user_id();
if !verification
.throttle
.attempt(cx, "verification", &user_id, None)
.await?
{
tracing::warn!(panel = %signed.realm.prefix, "verification link request throttled");
return Err(too_many_requests(verification.throttle.window_secs()).into());
}
if let Some(email) = signed.user.email() {
let email = email.to_string();
let locale = signed.user.locale();
after_response(cx, "verification link", move |cx, panel| async move {
let cx = crate::i18n::addressed_to(&cx, locale.as_ref());
mail_link(&cx, &panel, &user_id, &email).await
});
}
set_notification(
cx,
Notification::success(crate::t!(cx, "tablo-reset-sent"))
.description(crate::t!(cx, "tablo-verify-sent-description")),
);
see_other(notice_url(cx)).into_response(cx)
})
}
pub(super) fn confirm_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
let verification = panel
.auth
.verification()
.ok_or_else(topcoat::router::error::not_found)?;
let encoded = token_from_query(cx);
let mut db = crate::db::db(cx)?;
let Some(row) = token::live(cx, &mut db, TokenPurpose::EmailVerification, &encoded).await?
else {
return invalid_link(cx).await;
};
if !stores(cx, panel, &row).await? {
return invalid_link(cx).await;
}
let email = row.email.clone().unwrap_or_default();
let verified = run_serializable!(cx, |tx| async {
if token::live(cx, tx, TokenPurpose::EmailVerification, &encoded)
.await?
.is_none()
{
return Ok(false);
}
verification
.verifier()
.store_verified_email(cx, &row.user_id, &email, tx)
.await
.map_err(infrastructure_failure)?;
token::spend(tx, &row).await?;
Ok(true)
})?;
if !verified {
return invalid_link(cx).await;
}
set_notification(
cx,
Notification::success(crate::t!(cx, "tablo-verify-done")),
);
let signed_in_here = match panel.auth.authenticator() {
Some(authenticator) => resolve(cx, panel, authenticator)
.await?
.is_some_and(|signed| {
signed.user.user_id() == row.user_id && signed.impersonator.is_none()
}),
None => false,
};
let target = if signed_in_here {
panel_root(cx)
} else {
login_url(cx)
};
private(see_other(target).into_response(cx)?)
})
}
async fn stores(cx: &Cx, panel: &Realm, row: &token::AuthToken) -> topcoat::Result<bool> {
let Some(authenticator) = panel.auth.authenticator() else {
return Ok(false);
};
let user = authenticator
.find_by_id(cx, &row.user_id)
.await
.map_err(infrastructure_failure)?;
Ok(user.is_some_and(|user| {
user.can_access_panel(&panel.prefix)
&& user.email().is_some()
&& user.email() == row.email.as_deref()
}))
}
pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
use crate::{DeclarationErrorKind, declaration::VerificationFault};
if auth.verification.is_some() && auth.authenticator().is_none() {
return vec![DeclarationErrorKind::EmailVerification(
VerificationFault::AuthDisabled,
)];
}
Vec::new()
}