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 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.throttle.attempt(&user_id, None) {
332 tracing::warn!(panel = %signed.realm.prefix, "verification link request throttled");
333 return Err(too_many_requests(verification.throttle.window_secs()).into());
334 }
335 if let Some(email) = signed.user.email() {
336 let email = email.to_string();
337 let locale = signed.user.locale();
338 after_response(cx, "verification link", move |cx, panel| async move {
339 let cx = crate::i18n::addressed_to(&cx, locale.as_ref());
340 mail_link(&cx, &panel, &user_id, &email).await
341 });
342 }
343 set_notification(
344 cx,
345 Notification::success(crate::t!(cx, "tablo-reset-sent"))
346 .description(crate::t!(cx, "tablo-verify-sent-description")),
347 );
348 see_other(notice_url(cx)).into_response(cx)
349 })
350}
351
352pub(super) fn confirm_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
358 Box::pin(async move {
359 let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
360 let verification = panel
361 .auth
362 .verification()
363 .ok_or_else(topcoat::router::error::not_found)?;
364 let encoded = token_from_query(cx);
365 let mut db = crate::db::db(cx)?;
366 let Some(row) = token::live(cx, &mut db, TokenPurpose::EmailVerification, &encoded).await?
367 else {
368 return invalid_link(cx).await;
369 };
370 if !stores(cx, panel, &row).await? {
373 return invalid_link(cx).await;
374 }
375 let email = row.email.clone().unwrap_or_default();
376 let verified = run_serializable!(cx, |tx| async {
377 if token::live(cx, tx, TokenPurpose::EmailVerification, &encoded)
379 .await?
380 .is_none()
381 {
382 return Ok(false);
383 }
384 verification
385 .verifier()
386 .store_verified_email(cx, &row.user_id, &email, tx)
387 .await
388 .map_err(infrastructure_failure)?;
389 token::spend(tx, &row).await?;
390 Ok(true)
391 })?;
392 if !verified {
393 return invalid_link(cx).await;
394 }
395 set_notification(
396 cx,
397 Notification::success(crate::t!(cx, "tablo-verify-done")),
398 );
399 let signed_in_here = match panel.auth.authenticator() {
401 Some(authenticator) => resolve(cx, panel, authenticator)
402 .await?
403 .is_some_and(|signed| {
404 signed.user.user_id() == row.user_id && signed.impersonator.is_none()
405 }),
406 None => false,
407 };
408 let target = if signed_in_here {
409 panel_root(cx)
410 } else {
411 login_url(cx)
412 };
413 private(see_other(target).into_response(cx)?)
414 })
415}
416
417async fn stores(cx: &Cx, panel: &Realm, row: &token::AuthToken) -> topcoat::Result<bool> {
420 let Some(authenticator) = panel.auth.authenticator() else {
421 return Ok(false);
422 };
423 let user = authenticator
424 .find_by_id(cx, &row.user_id)
425 .await
426 .map_err(infrastructure_failure)?;
427 Ok(user.is_some_and(|user| {
428 user.can_access_panel(&panel.prefix)
429 && user.email().is_some()
430 && user.email() == row.email.as_deref()
431 }))
432}
433
434pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
437 use crate::{DeclarationErrorKind, declaration::VerificationFault};
438
439 if auth.verification.is_some() && auth.authenticator().is_none() {
440 return vec![DeclarationErrorKind::EmailVerification(
441 VerificationFault::AuthDisabled,
442 )];
443 }
444 Vec::new()
445}