From 125afd61c0e100a352a1ac7fe0cdaef17859721a Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 24 May 2022 15:53:35 +0200 Subject: [PATCH] Make email verification mandatory --- .../handlers/src/compat/login_sso_complete.rs | 26 +++++ crates/handlers/src/lib.rs | 15 ++- .../handlers/src/views/account/emails/add.rs | 101 ++++++++++++++++++ .../handlers/src/views/account/emails/mod.rs | 13 ++- .../src/views/account/emails/verify.rs | 16 +-- crates/router/src/endpoints.rs | 56 +++++++++- crates/templates/src/context.rs | 51 +++++++++ crates/templates/src/lib.rs | 16 +-- .../src/res/pages/account/emails/add.html | 39 +++++++ .../{emails.html => emails/index.html} | 0 .../pages/account/{ => emails}/verify.html | 2 - 11 files changed, 310 insertions(+), 25 deletions(-) create mode 100644 crates/handlers/src/views/account/emails/add.rs create mode 100644 crates/templates/src/res/pages/account/emails/add.html rename crates/templates/src/res/pages/account/{emails.html => emails/index.html} (100%) rename crates/templates/src/res/pages/account/{ => emails}/verify.html (99%) diff --git a/crates/handlers/src/compat/login_sso_complete.rs b/crates/handlers/src/compat/login_sso_complete.rs index f4e77ebc..2ff1df03 100644 --- a/crates/handlers/src/compat/login_sso_complete.rs +++ b/crates/handlers/src/compat/login_sso_complete.rs @@ -65,6 +65,19 @@ pub async fn get( return Ok((cookie_jar, login.go()).into_response()); }; + // TODO: make that more generic + if session + .user + .primary_email + .as_ref() + .and_then(|e| e.confirmed_at) + .is_none() + { + let destination = mas_router::AccountAddEmail::default() + .and_then(PostAuthAction::ContinueCompatSsoLogin { data: id }); + return Ok((cookie_jar, destination.go()).into_response()); + } + let login = get_compat_sso_login_by_id(&mut conn, id).await?; // Bail out if that login session is more than 30min old @@ -108,6 +121,19 @@ pub async fn post( return Ok((cookie_jar, login.go()).into_response()); }; + // TODO: make that more generic + if session + .user + .primary_email + .as_ref() + .and_then(|e| e.confirmed_at) + .is_none() + { + let destination = mas_router::AccountAddEmail::default() + .and_then(PostAuthAction::ContinueCompatSsoLogin { data: id }); + return Ok((cookie_jar, destination.go()).into_response()); + } + let login = get_compat_sso_login_by_id(&mut txn, id).await?; // Bail out if that login session is more than 30min old diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 3d81d18a..fd44500e 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -158,11 +158,6 @@ where mas_router::Register::route(), get(self::views::register::get).post(self::views::register::post), ) - .route( - mas_router::AccountVerifyEmail::route(), - get(self::views::account::emails::verify::get) - .post(self::views::account::emails::verify::post), - ) .route(mas_router::Account::route(), get(self::views::account::get)) .route( mas_router::AccountPassword::route(), @@ -172,6 +167,16 @@ where mas_router::AccountEmails::route(), get(self::views::account::emails::get).post(self::views::account::emails::post), ) + .route( + mas_router::AccountVerifyEmail::route(), + get(self::views::account::emails::verify::get) + .post(self::views::account::emails::verify::post), + ) + .route( + mas_router::AccountAddEmail::route(), + get(self::views::account::emails::add::get) + .post(self::views::account::emails::add::post), + ) .route( mas_router::OAuth2AuthorizationEndpoint::route(), get(self::oauth2::authorization::get), diff --git a/crates/handlers/src/views/account/emails/add.rs b/crates/handlers/src/views/account/emails/add.rs new file mode 100644 index 00000000..9cc2b726 --- /dev/null +++ b/crates/handlers/src/views/account/emails/add.rs @@ -0,0 +1,101 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use axum::{ + extract::{Extension, Form, Query}, + response::{Html, IntoResponse, Response}, +}; +use axum_extra::extract::PrivateCookieJar; +use mas_axum_utils::{ + csrf::{CsrfExt, ProtectedForm}, + FancyError, SessionInfoExt, +}; +use mas_config::Encrypter; +use mas_email::Mailer; +use mas_router::Route; +use mas_storage::user::add_user_email; +use mas_templates::{EmailAddContext, TemplateContext, Templates}; +use serde::Deserialize; +use sqlx::PgPool; + +use super::start_email_verification; +use crate::views::shared::OptionalPostAuthAction; + +#[derive(Deserialize, Debug)] +pub struct EmailForm { + email: String, +} + +pub(crate) async fn get( + Extension(templates): Extension, + Extension(pool): Extension, + cookie_jar: PrivateCookieJar, +) -> Result { + let mut conn = pool.begin().await?; + + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(); + let (session_info, cookie_jar) = cookie_jar.session_info(); + + let maybe_session = session_info.load_session(&mut conn).await?; + + let session = if let Some(session) = maybe_session { + session + } else { + let login = mas_router::Login::default(); + return Ok((cookie_jar, login.go()).into_response()); + }; + + let ctx = EmailAddContext::new() + .with_session(session) + .with_csrf(csrf_token.form_value()); + + let content = templates.render_account_add_email(&ctx).await?; + + Ok((cookie_jar, Html(content)).into_response()) +} + +pub(crate) async fn post( + Extension(pool): Extension, + Extension(mailer): Extension, + cookie_jar: PrivateCookieJar, + Query(query): Query, + Form(form): Form>, +) -> Result { + let mut txn = pool.begin().await?; + + let form = cookie_jar.verify_form(form)?; + let (session_info, cookie_jar) = cookie_jar.session_info(); + + let maybe_session = session_info.load_session(&mut txn).await?; + + let session = if let Some(session) = maybe_session { + session + } else { + let login = mas_router::Login::default(); + return Ok((cookie_jar, login.go()).into_response()); + }; + + let user_email = add_user_email(&mut txn, &session.user, form.email).await?; + let next = mas_router::AccountVerifyEmail::new(user_email.data); + let next = if let Some(action) = query.post_auth_action { + next.and_then(action) + } else { + next + }; + start_email_verification(&mailer, &mut txn, &session.user, user_email).await?; + + txn.commit().await?; + + Ok((cookie_jar, next.go()).into_response()) +} diff --git a/crates/handlers/src/views/account/emails/mod.rs b/crates/handlers/src/views/account/emails/mod.rs index 854339ec..1e2f6c27 100644 --- a/crates/handlers/src/views/account/emails/mod.rs +++ b/crates/handlers/src/views/account/emails/mod.rs @@ -39,12 +39,14 @@ use serde::Deserialize; use sqlx::{PgExecutor, PgPool}; use tracing::info; +pub mod add; pub mod verify; #[derive(Deserialize, Debug)] #[serde(tag = "action", rename_all = "snake_case")] pub enum ManagementForm { Add { email: String }, + ResendConfirmation { data: String }, SetPrimary { data: String }, Remove { data: String }, } @@ -140,7 +142,16 @@ pub(crate) async fn post( match form { ManagementForm::Add { email } => { let user_email = add_user_email(&mut txn, &session.user, email).await?; - let next = mas_router::AccountVerifyEmail(user_email.data); + let next = mas_router::AccountVerifyEmail::new(user_email.data); + start_email_verification(&mailer, &mut txn, &session.user, user_email).await?; + txn.commit().await?; + return Ok((cookie_jar, next.go()).into_response()); + } + ManagementForm::ResendConfirmation { data } => { + let id = data.parse()?; + + let user_email = get_user_email(&mut txn, &session.user, id).await?; + let next = mas_router::AccountVerifyEmail::new(user_email.data); start_email_verification(&mailer, &mut txn, &session.user, user_email).await?; txn.commit().await?; return Ok((cookie_jar, next.go()).into_response()); diff --git a/crates/handlers/src/views/account/emails/verify.rs b/crates/handlers/src/views/account/emails/verify.rs index 64ead17d..29c524c6 100644 --- a/crates/handlers/src/views/account/emails/verify.rs +++ b/crates/handlers/src/views/account/emails/verify.rs @@ -26,7 +26,7 @@ use mas_config::Encrypter; use mas_router::Route; use mas_storage::user::{ consume_email_verification, lookup_user_email_by_id, lookup_user_email_verification_code, - mark_user_email_as_verified, + mark_user_email_as_verified, set_user_email_as_primary, }; use mas_templates::{EmailVerificationPageContext, TemplateContext, Templates}; use serde::Deserialize; @@ -46,12 +46,12 @@ pub(crate) async fn get( Path(id): Path, cookie_jar: PrivateCookieJar, ) -> Result { - let mut txn = pool.begin().await?; + let mut conn = pool.acquire().await?; let (csrf_token, cookie_jar) = cookie_jar.csrf_token(); let (session_info, cookie_jar) = cookie_jar.session_info(); - let maybe_session = session_info.load_session(&mut txn).await?; + let maybe_session = session_info.load_session(&mut conn).await?; let session = if let Some(session) = maybe_session { session @@ -60,7 +60,7 @@ pub(crate) async fn get( return Ok((cookie_jar, login.go()).into_response()); }; - let user_email = lookup_user_email_by_id(&mut txn, &session.user, id).await?; + let user_email = lookup_user_email_by_id(&mut conn, &session.user, id).await?; if user_email.confirmed_at.is_some() { // This email was already verified, skip @@ -72,9 +72,7 @@ pub(crate) async fn get( .with_session(session) .with_csrf(csrf_token.form_value()); - let content = templates.render_email_verification_form(&ctx).await?; - - txn.commit().await?; + let content = templates.render_account_verify_email(&ctx).await?; Ok((cookie_jar, Html(content)).into_response()) } @@ -102,6 +100,10 @@ pub(crate) async fn post( let email = lookup_user_email_by_id(&mut txn, &session.user, id).await?; + if session.user.primary_email.is_none() { + set_user_email_as_primary(&mut txn, &email).await?; + } + // TODO: make those 8 hours configurable let verification = lookup_user_email_verification_code(&mut txn, email, &form.code, Duration::hours(8)) diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index c7f5bacc..89e92cc2 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -315,18 +315,66 @@ impl From> for Register { } } -/// `GET /account/emails/verify/:id` +/// `GET|POST /account/emails/verify/:id` #[derive(Debug, Clone)] -pub struct AccountVerifyEmail(pub i64); +pub struct AccountVerifyEmail { + id: i64, + post_auth_action: Option, +} + +impl AccountVerifyEmail { + #[must_use] + pub fn new(id: i64) -> Self { + Self { + id, + post_auth_action: None, + } + } + + #[must_use] + pub fn and_then(mut self, action: PostAuthAction) -> Self { + self.post_auth_action = Some(action); + self + } +} impl Route for AccountVerifyEmail { - type Query = (); + type Query = PostAuthAction; fn route() -> &'static str { "/account/emails/verify/:id" } fn path(&self) -> std::borrow::Cow<'static, str> { - format!("/account/emails/verify/{}", self.0).into() + format!("/account/emails/verify/{}", self.id).into() + } + + fn query(&self) -> Option<&Self::Query> { + self.post_auth_action.as_ref() + } +} + +/// `GET /account/emails/add` +#[derive(Default, Debug, Clone)] +pub struct AccountAddEmail { + post_auth_action: Option, +} + +impl Route for AccountAddEmail { + type Query = PostAuthAction; + fn route() -> &'static str { + "/account/emails/add" + } + + fn query(&self) -> Option<&Self::Query> { + self.post_auth_action.as_ref() + } +} + +impl AccountAddEmail { + #[must_use] + pub fn and_then(mut self, action: PostAuthAction) -> Self { + self.post_auth_action = Some(action); + self } } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 4c26971e..bb92c0d2 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -641,6 +641,12 @@ impl EmailVerificationPageContext { email: email.into(), } } + + /// Set the form state + #[must_use] + pub fn with_form_state(self, form: FormState) -> Self { + Self { form, ..self } + } } impl TemplateContext for EmailVerificationPageContext { @@ -662,6 +668,51 @@ impl TemplateContext for EmailVerificationPageContext { } } +/// Fields of the account email add form +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EmailAddFormField { + /// The email + Email, +} + +impl FormField for EmailAddFormField { + fn keep(&self) -> bool { + match self { + Self::Email => true, + } + } +} + +/// Context used by the `pages/account/verify.html` templates +#[derive(Serialize, Default)] +pub struct EmailAddContext { + form: FormState, +} + +impl EmailAddContext { + /// Constructs a context for the email add page + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Set the form state + #[must_use] + pub fn with_form_state(form: FormState) -> Self { + Self { form } + } +} + +impl TemplateContext for EmailAddContext { + fn sample() -> Vec + where + Self: Sized, + { + vec![Self::default()] + } +} + /// Context used by the `form_post.html` template #[derive(Serialize)] pub struct FormPostContext { diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index d4a39827..9e608b4c 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -45,7 +45,7 @@ mod macros; pub use self::{ context::{ - AccountContext, AccountEmailsContext, CompatSsoContext, ConsentContext, + AccountContext, AccountEmailsContext, CompatSsoContext, ConsentContext, EmailAddContext, EmailVerificationContext, EmailVerificationPageContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField, PostAuthContext, ReauthContext, ReauthFormField, RegisterContext, RegisterFormField, TemplateContext, @@ -312,7 +312,13 @@ register_templates! { pub fn render_account_password(WithCsrf>) { "pages/account/password.html" } /// Render the emails management - pub fn render_account_emails(WithCsrf>>) { "pages/account/emails.html" } + pub fn render_account_emails(WithCsrf>>) { "pages/account/emails/index.html" } + + /// Render the email verification page + pub fn render_account_verify_email(WithCsrf>) { "pages/account/emails/verify.html" } + + /// Render the email verification page + pub fn render_account_add_email(WithCsrf>) { "pages/account/emails/add.html" } /// Render the re-authentication form pub fn render_reauth(WithCsrf>) { "pages/reauth.html" } @@ -331,9 +337,6 @@ register_templates! { /// Render the email verification subject pub fn render_email_verification_subject(EmailVerificationContext) { "emails/verification.subject" } - - /// Render the email post-email verification page - pub fn render_email_verification_form(WithCsrf>) { "pages/account/verify.html" } } impl Templates { @@ -348,13 +351,14 @@ impl Templates { check::render_account_index(self).await?; check::render_account_password(self).await?; check::render_account_emails::<()>(self).await?; + check::render_account_add_email(self).await?; + check::render_account_verify_email(self).await?; check::render_reauth(self).await?; check::render_form_post::(self).await?; check::render_error(self).await?; check::render_email_verification_txt(self).await?; check::render_email_verification_html(self).await?; check::render_email_verification_subject(self).await?; - check::render_email_verification_form(self).await?; Ok(()) } } diff --git a/crates/templates/src/res/pages/account/emails/add.html b/crates/templates/src/res/pages/account/emails/add.html new file mode 100644 index 00000000..63ad8fed --- /dev/null +++ b/crates/templates/src/res/pages/account/emails/add.html @@ -0,0 +1,39 @@ +{# +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +#} + +{% extends "base.html" %} + +{% block content %} + {{ navbar::top() }} +
+
+
+

Add an email address

+
+ + {% if form.errors is not empty %} + {% for error in form.errors %} +
+ {{ errors::form_error_message(error=error) }} +
+ {% endfor %} + {% endif %} + + + {{ field::input(label="Email", name="email", type="email", form_state=form, autocomplete="email") }} + {{ button::button(text="Next") }} +
+{% endblock content %} diff --git a/crates/templates/src/res/pages/account/emails.html b/crates/templates/src/res/pages/account/emails/index.html similarity index 100% rename from crates/templates/src/res/pages/account/emails.html rename to crates/templates/src/res/pages/account/emails/index.html diff --git a/crates/templates/src/res/pages/account/verify.html b/crates/templates/src/res/pages/account/emails/verify.html similarity index 99% rename from crates/templates/src/res/pages/account/verify.html rename to crates/templates/src/res/pages/account/emails/verify.html index cf7c05ff..4319acb2 100644 --- a/crates/templates/src/res/pages/account/verify.html +++ b/crates/templates/src/res/pages/account/emails/verify.html @@ -38,5 +38,3 @@ limitations under the License. {{ button::button(text="Submit") }} {% endblock content %} - -