1mod pages;
6
7use std::{
8 any::{Any, TypeId},
9 future::Future,
10 marker::PhantomData,
11 time::Duration,
12};
13
14use topcoat::{
15 context::Cx,
16 router::{
17 Body, RouteFuture,
18 error::{see_other, too_many_requests},
19 response::IntoResponse,
20 },
21};
22
23pub(super) use self::pages::invalid_link;
24use self::pages::notice;
25use super::{
26 LoginThrottle, PanelUser, infrastructure_failure,
27 login::login_url,
28 mail::{after_response, private, token_from_query},
29 panel_root, resolved,
30 session::resolve,
31 token::{self, TokenPurpose, run_serializable},
32 unauthenticated_error,
33};
34use crate::{
35 BoxFuture,
36 auth::realm::{Realm, current_realm, hosted, panel_prefix},
37 flash::set_notification,
38 outcome::Notification,
39};
40
41pub const VERIFICATION_LIFETIME: Duration = Duration::from_hours(24);
43
44pub trait EmailVerifier: Send + Sync + 'static {
99 type User: PanelUser;
102
103 fn is_verified(&self, user: &Self::User) -> bool;
106
107 fn store_verified_email(
114 &self,
115 cx: &Cx,
116 user_id: &str,
117 email: &str,
118 ex: &mut dyn toasty::Executor,
119 ) -> impl Future<Output = topcoat::Result<()>> + Send;
120}
121
122pub(crate) trait DynEmailVerifier: Send + Sync {
124 fn is_verified(&self, user: &dyn PanelUser) -> bool;
126 fn store_verified_email<'a>(
127 &'a self,
128 cx: &'a Cx,
129 user_id: &'a str,
130 email: &'a str,
131 ex: &'a mut dyn toasty::Executor,
132 ) -> BoxFuture<'a, topcoat::Result<()>>;
133 fn user_type(&self) -> TypeId;
135}
136
137impl<V: EmailVerifier> DynEmailVerifier for V {
138 fn is_verified(&self, user: &dyn PanelUser) -> bool {
139 let user: &dyn Any = user;
140 user.downcast_ref::<V::User>()
141 .is_some_and(|user| EmailVerifier::is_verified(self, user))
142 }
143
144 fn store_verified_email<'a>(
145 &'a self,
146 cx: &'a Cx,
147 user_id: &'a str,
148 email: &'a str,
149 ex: &'a mut dyn toasty::Executor,
150 ) -> BoxFuture<'a, topcoat::Result<()>> {
151 Box::pin(EmailVerifier::store_verified_email(
152 self, cx, user_id, email, ex,
153 ))
154 }
155
156 fn user_type(&self) -> TypeId {
157 TypeId::of::<V::User>()
158 }
159}
160
161pub struct EmailVerification<U> {
186 verifier: Box<dyn DynEmailVerifier>,
187 pub(in crate::auth) throttle: LoginThrottle,
188 user: PhantomData<fn() -> U>,
189}
190
191impl<U: PanelUser> EmailVerification<U> {
192 #[must_use]
194 pub fn new(verifier: impl EmailVerifier<User = U>) -> Self {
195 Self {
196 verifier: Box::new(verifier),
197 throttle: LoginThrottle::new(3, Duration::from_mins(15)),
198 user: PhantomData,
199 }
200 }
201
202 #[must_use]
205 pub fn throttle(mut self, throttle: LoginThrottle) -> Self {
206 self.throttle = throttle;
207 self
208 }
209
210 pub(crate) fn erase(self) -> EmailVerification<()> {
212 EmailVerification {
213 verifier: self.verifier,
214 throttle: self.throttle,
215 user: PhantomData,
216 }
217 }
218}
219
220impl EmailVerification<()> {
221 pub(crate) fn verifier(&self) -> &dyn DynEmailVerifier {
222 &*self.verifier
223 }
224}
225
226impl<U> std::fmt::Debug for EmailVerification<U> {
227 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228 f.debug_struct("EmailVerification")
229 .field("throttle", &self.throttle)
230 .finish_non_exhaustive()
231 }
232}
233
234pub(crate) fn notice_url(cx: &Cx) -> String {
236 format!("{}/verify-email", panel_prefix(cx))
237}
238
239pub(crate) fn confirm_url(cx: &Cx) -> String {
241 format!("{}/verify-email/confirm", panel_prefix(cx))
242}
243
244pub(crate) fn pending(panel: &Realm, user: &dyn PanelUser) -> bool {
247 user.email().is_some()
248 && panel
249 .auth
250 .verification()
251 .is_some_and(|verification| !verification.verifier().is_verified(user))
252}
253
254pub(super) fn mail_after_sign_up(cx: &Cx, user: &dyn PanelUser) {
257 let Some(panel) = current_realm(cx) else {
258 return;
259 };
260 let Some(email) = user.email().filter(|_| pending(panel, user)) else {
261 return;
262 };
263 let (email, user_id) = (email.to_string(), user.user_id());
264 let locale = user.locale();
265 after_response(cx, "verification link", move |cx, panel| async move {
266 let cx = crate::i18n::addressed_to(&cx, locale.as_ref());
267 mail_link(&cx, &panel, &user_id, &email).await
268 });
269}
270
271async fn mail_link(cx: &Cx, panel: &Realm, user_id: &str, email: &str) -> topcoat::Result<()> {
274 let mail = panel
275 .auth
276 .auth_mail()
277 .ok_or_else(topcoat::router::error::not_found)?;
278 let token = token::issue_alone(
279 cx,
280 TokenPurpose::EmailVerification,
281 user_id,
282 Some(email.to_string()),
283 VERIFICATION_LIFETIME,
284 )
285 .await?;
286 let link = mail.link(&confirm_url(cx), &token);
287 let brand = hosted(cx)?.brand_name(cx);
288 let message = mail.compose(
289 email,
290 crate::t!(cx, "tablo-verify-mail-subject", brand = brand.clone()),
291 crate::t!(
292 cx,
293 "tablo-verify-mail-body",
294 email = email,
295 brand = brand,
296 link = link
297 ),
298 )?;
299 topcoat::mail::send(cx, message).await?;
300 Ok(())
301}
302
303pub(super) fn notice_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
306 Box::pin(async move {
307 let signed = resolved(cx).ok_or_else(|| unauthenticated_error(cx))?;
308 if !pending(&signed.realm, signed.user.as_ref()) {
309 return see_other(panel_root(cx)).into_response(cx);
310 }
311 notice(cx, signed.user.email().unwrap_or_default()).await
312 })
313}
314
315pub(super) fn notice_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
318 Box::pin(async move {
319 let signed = resolved(cx).ok_or_else(|| unauthenticated_error(cx))?;
320 let verification = signed
321 .realm
322 .auth
323 .verification()
324 .ok_or_else(topcoat::router::error::not_found)?;
325 let form = crate::auth::input::read_form(cx, body).await?;
326 crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
327 if !pending(&signed.realm, signed.user.as_ref()) {
328 return see_other(panel_root(cx)).into_response(cx);
329 }
330 let user_id = signed.user.user_id();
331 if !verification
332 .throttle
333 .attempt(cx, "verification", &user_id, None)
334 .await?
335 {
336 tracing::warn!(panel = %signed.realm.prefix, "verification link request throttled");
337 return Err(too_many_requests(verification.throttle.window_secs()).into());
338 }
339 if let Some(email) = signed.user.email() {
340 let email = email.to_string();
341 let locale = signed.user.locale();
342 after_response(cx, "verification link", move |cx, panel| async move {
343 let cx = crate::i18n::addressed_to(&cx, locale.as_ref());
344 mail_link(&cx, &panel, &user_id, &email).await
345 });
346 }
347 set_notification(
348 cx,
349 Notification::success(crate::t!(cx, "tablo-reset-sent"))
350 .description(crate::t!(cx, "tablo-verify-sent-description")),
351 );
352 see_other(notice_url(cx)).into_response(cx)
353 })
354}
355
356pub(super) fn confirm_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
362 Box::pin(async move {
363 let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
364 let verification = panel
365 .auth
366 .verification()
367 .ok_or_else(topcoat::router::error::not_found)?;
368 let encoded = token_from_query(cx);
369 let mut db = crate::db::db(cx)?;
370 let Some(row) = token::live(cx, &mut db, TokenPurpose::EmailVerification, &encoded).await?
371 else {
372 return invalid_link(cx).await;
373 };
374 if !stores(cx, panel, &row).await? {
377 return invalid_link(cx).await;
378 }
379 let email = row.email.clone().unwrap_or_default();
380 let verified = run_serializable!(cx, |tx| async {
381 if token::live(cx, tx, TokenPurpose::EmailVerification, &encoded)
383 .await?
384 .is_none()
385 {
386 return Ok(false);
387 }
388 verification
389 .verifier()
390 .store_verified_email(cx, &row.user_id, &email, tx)
391 .await
392 .map_err(infrastructure_failure)?;
393 token::spend(tx, &row).await?;
394 Ok(true)
395 })?;
396 if !verified {
397 return invalid_link(cx).await;
398 }
399 set_notification(
400 cx,
401 Notification::success(crate::t!(cx, "tablo-verify-done")),
402 );
403 let signed_in_here = match panel.auth.authenticator() {
405 Some(authenticator) => resolve(cx, panel, authenticator)
406 .await?
407 .is_some_and(|signed| {
408 signed.user.user_id() == row.user_id && signed.impersonator.is_none()
409 }),
410 None => false,
411 };
412 let target = if signed_in_here {
413 panel_root(cx)
414 } else {
415 login_url(cx)
416 };
417 private(see_other(target).into_response(cx)?)
418 })
419}
420
421async fn stores(cx: &Cx, panel: &Realm, row: &token::AuthToken) -> topcoat::Result<bool> {
424 let Some(authenticator) = panel.auth.authenticator() else {
425 return Ok(false);
426 };
427 let user = authenticator
428 .find_by_id(cx, &row.user_id)
429 .await
430 .map_err(infrastructure_failure)?;
431 Ok(user.is_some_and(|user| {
432 user.can_access_panel(&panel.prefix)
433 && user.email().is_some()
434 && user.email() == row.email.as_deref()
435 }))
436}
437
438pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
441 use crate::{DeclarationErrorKind, declaration::VerificationFault};
442
443 if auth.verification.is_some() && auth.authenticator().is_none() {
444 return vec![DeclarationErrorKind::EmailVerification(
445 VerificationFault::AuthDisabled,
446 )];
447 }
448 Vec::new()
449}