diff --git a/crates/handlers/src/views/account/emails.rs b/crates/handlers/src/views/account/emails.rs index 7a6abdd2..518c4553 100644 --- a/crates/handlers/src/views/account/emails.rs +++ b/crates/handlers/src/views/account/emails.rs @@ -13,13 +13,13 @@ // limitations under the License. use lettre::{message::Mailbox, Address}; -use mas_config::{CookiesConfig, CsrfConfig}; -use mas_data_model::BrowserSession; +use mas_config::{CookiesConfig, CsrfConfig, OAuth2Config}; +use mas_data_model::{BrowserSession, User, UserEmail}; use mas_email::Mailer; use mas_storage::{ user::{ - add_user_email, get_user_email, get_user_emails, remove_user_email, - set_user_email_as_primary, + add_user_email, add_user_email_verification_code, get_user_email, get_user_emails, + remove_user_email, set_user_email_as_primary, }, PostgresqlBackend, }; @@ -34,6 +34,7 @@ use mas_warp_utils::{ with_templates, CsrfToken, }, }; +use rand::{distributions::Alphanumeric, thread_rng, Rng}; use serde::Deserialize; use sqlx::{pool::PoolConnection, PgExecutor, PgPool, Postgres, Transaction}; use tracing::info; @@ -44,11 +45,14 @@ pub(super) fn filter( pool: &PgPool, templates: &Templates, mailer: &Mailer, + oauth2_config: &OAuth2Config, csrf_config: &CsrfConfig, cookies_config: &CookiesConfig, ) -> BoxedFilter<(Box,)> { let mailer = mailer.clone(); + let base = oauth2_config.issuer.clone(); + let get = with_templates(templates) .and(encrypted_cookie_saver(cookies_config)) .and(updated_csrf_token(cookies_config, csrf_config)) @@ -58,6 +62,7 @@ pub(super) fn filter( let post = with_templates(templates) .and(warp::any().map(move || mailer.clone())) + .and(warp::any().map(move || base.clone())) .and(encrypted_cookie_saver(cookies_config)) .and(updated_csrf_token(cookies_config, csrf_config)) .and(session(pool, cookies_config)) @@ -113,9 +118,43 @@ async fn render( Ok(Box::new(reply)) } +async fn start_email_verification( + mailer: &Mailer, + base: &Url, + executor: impl PgExecutor<'_>, + user: &User, + user_email: &UserEmail, +) -> anyhow::Result<()> { + // First, generate a code + let code: String = thread_rng() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect(); + + add_user_email_verification_code(executor, user_email, &code).await?; + + // And send the verification email + let address: Address = user_email.email.parse()?; + + let mailbox = Mailbox::new(Some(user.username.clone()), address); + + let link = base.join("./verify/")?; + let link = link.join(&code)?; + + let context = EmailVerificationContext::new(user.clone().into(), link); + + mailer.send_verification_email(mailbox, &context).await?; + + info!(email.id = user_email.data, "Verification email sent"); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] async fn post( templates: Templates, mailer: Mailer, + base: Url, cookie_saver: EncryptedCookieSaver, csrf_token: CsrfToken, mut session: BrowserSession, @@ -124,9 +163,10 @@ async fn post( ) -> Result, Rejection> { match form { Form::Add { email } => { - // TODO: verify email format - // TODO: send verification email - add_user_email(&mut txn, &session.user, email) + let user_email = add_user_email(&mut txn, &session.user, email) + .await + .wrap_error()?; + start_email_verification(&mailer, &base, &mut txn, &session.user, &user_email) .await .wrap_error()?; } @@ -140,27 +180,13 @@ async fn post( Form::ResendConfirmation { data } => { let id: i64 = data.parse().wrap_error()?; - let email: Address = get_user_email(&mut txn, &session.user, id) - .await - .wrap_error()? - .email - .parse() - .wrap_error()?; - - let mailbox = Mailbox::new(Some(session.user.username.clone()), email); - - // TODO: actually generate a verification link - let context = EmailVerificationContext::new( - session.user.clone().into(), - Url::parse("https://example.com/verify").unwrap(), - ); - - mailer - .send_verification_email(mailbox, &context) + let user_email = get_user_email(&mut txn, &session.user, id) .await .wrap_error()?; - info!(email.id = id, "Verification email sent"); + start_email_verification(&mailer, &base, &mut txn, &session.user, &user_email) + .await + .wrap_error()?; } Form::SetPrimary { data } => { let id = data.parse().wrap_error()?; diff --git a/crates/handlers/src/views/account/mod.rs b/crates/handlers/src/views/account/mod.rs index 74989daf..0c041f1f 100644 --- a/crates/handlers/src/views/account/mod.rs +++ b/crates/handlers/src/views/account/mod.rs @@ -15,7 +15,7 @@ mod emails; mod password; -use mas_config::{CookiesConfig, CsrfConfig}; +use mas_config::{CookiesConfig, CsrfConfig, OAuth2Config}; use mas_data_model::BrowserSession; use mas_email::Mailer; use mas_storage::{ @@ -42,6 +42,7 @@ pub(super) fn filter( pool: &PgPool, templates: &Templates, mailer: &Mailer, + oauth2_config: &OAuth2Config, csrf_config: &CsrfConfig, cookies_config: &CookiesConfig, ) -> BoxedFilter<(Box,)> { @@ -55,7 +56,14 @@ pub(super) fn filter( let index = warp::path::end().and(get); let password = password(pool, templates, csrf_config, cookies_config); - let emails = emails(pool, templates, mailer, csrf_config, cookies_config); + let emails = emails( + pool, + templates, + mailer, + oauth2_config, + csrf_config, + cookies_config, + ); let filter = index.or(password).unify().or(emails).unify(); diff --git a/crates/handlers/src/views/mod.rs b/crates/handlers/src/views/mod.rs index 8a1c22de..7f16ffb0 100644 --- a/crates/handlers/src/views/mod.rs +++ b/crates/handlers/src/views/mod.rs @@ -1,4 +1,4 @@ -// Copyright 2021 The Matrix.org Foundation C.I.C. +// Copyright 2021-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. @@ -25,10 +25,12 @@ mod logout; mod reauth; mod register; mod shared; +mod verify; use self::{ account::filter as account, index::filter as index, login::filter as login, logout::filter as logout, reauth::filter as reauth, register::filter as register, + verify::filter as verify, }; pub(crate) use self::{ login::LoginRequest, reauth::ReauthRequest, register::RegisterRequest, shared::PostAuthAction, @@ -43,11 +45,19 @@ pub(super) fn filter( cookies_config: &CookiesConfig, ) -> BoxedFilter<(Box,)> { let index = index(pool, templates, oauth2_config, csrf_config, cookies_config); - let account = account(pool, templates, mailer, csrf_config, cookies_config); + let account = account( + pool, + templates, + mailer, + oauth2_config, + csrf_config, + cookies_config, + ); let login = login(pool, templates, csrf_config, cookies_config); let register = register(pool, templates, csrf_config, cookies_config); let logout = logout(pool, cookies_config); let reauth = reauth(pool, templates, csrf_config, cookies_config); + let verify = verify(pool, templates, csrf_config, cookies_config); index .or(account) @@ -60,5 +70,7 @@ pub(super) fn filter( .unify() .or(reauth) .unify() + .or(verify) + .unify() .boxed() } diff --git a/crates/handlers/src/views/verify.rs b/crates/handlers/src/views/verify.rs new file mode 100644 index 00000000..91a095f4 --- /dev/null +++ b/crates/handlers/src/views/verify.rs @@ -0,0 +1,90 @@ +// 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 chrono::Duration; +use mas_config::{CookiesConfig, CsrfConfig}; +use mas_data_model::BrowserSession; +use mas_storage::{ + user::{ + consume_email_verification, lookup_user_email_verification_code, + mark_user_email_as_verified, + }, + PostgresqlBackend, +}; +use mas_templates::{EmptyContext, TemplateContext, Templates}; +use mas_warp_utils::{ + errors::WrapError, + filters::{ + cookies::{encrypted_cookie_saver, EncryptedCookieSaver}, + csrf::updated_csrf_token, + database::transaction, + session::optional_session, + with_templates, CsrfToken, + }, +}; +use sqlx::{PgPool, Postgres, Transaction}; +use warp::{filters::BoxedFilter, reply::html, Filter, Rejection, Reply}; + +pub(super) fn filter( + pool: &PgPool, + templates: &Templates, + csrf_config: &CsrfConfig, + cookies_config: &CookiesConfig, +) -> BoxedFilter<(Box,)> { + warp::path!("verify" / String) + .and(warp::get()) + .and(with_templates(templates)) + .and(encrypted_cookie_saver(cookies_config)) + .and(updated_csrf_token(cookies_config, csrf_config)) + .and(optional_session(pool, cookies_config)) + .and(transaction(pool)) + .and_then(get) + .boxed() +} + +async fn get( + code: String, + templates: Templates, + cookie_saver: EncryptedCookieSaver, + csrf_token: CsrfToken, + maybe_session: Option>, + mut txn: Transaction<'_, Postgres>, +) -> Result, Rejection> { + // TODO: make those 8 hours configurable + let verification = lookup_user_email_verification_code(&mut txn, &code, Duration::hours(8)) + .await + .wrap_error()?; + + // TODO: display nice errors if the code was already consumed or expired + + let verification = consume_email_verification(&mut txn, verification) + .await + .wrap_error()?; + + let _email = mark_user_email_as_verified(&mut txn, verification.email) + .await + .wrap_error()?; + + let ctx = EmptyContext + .maybe_with_session(maybe_session) + .with_csrf(csrf_token.form_value()); + + let content = templates.render_email_verification_done(&ctx).await?; + let reply = html(content); + let reply = cookie_saver.save_encrypted(&csrf_token, reply)?; + + txn.commit().await.wrap_error()?; + + Ok(Box::new(reply)) +} diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index d019fee0..1ca7411b 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -1,4 +1,4 @@ -// Copyright 2021 The Matrix.org Foundation C.I.C. +// Copyright 2021-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. @@ -318,6 +318,9 @@ register_templates! { /// Render the email verification email (plain text variant) pub fn render_email_verification_html(EmailVerificationContext) { "emails/verification.html" } + + /// Render the email post-email verification page + pub fn render_email_verification_done(WithCsrf>) { "pages/verify.html" } } impl Templates { @@ -335,6 +338,7 @@ impl Templates { check::render_error(self).await?; check::render_email_verification_txt(self).await?; check::render_email_verification_html(self).await?; + check::render_email_verification_done(self).await?; Ok(()) } } diff --git a/crates/templates/src/res/pages/verify.html b/crates/templates/src/res/pages/verify.html new file mode 100644 index 00000000..a3c71d83 --- /dev/null +++ b/crates/templates/src/res/pages/verify.html @@ -0,0 +1,25 @@ +{# +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 %} +
+

Email verified!

+
+{% endblock content %} + +