1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-07-31 09:24:31 +03:00

Switch email verification to a code-based flow

This commit is contained in:
Quentin Gliech
2022-05-24 12:31:05 +02:00
parent 35fa7c732a
commit 89597dbf81
18 changed files with 409 additions and 236 deletions

View File

@ -173,6 +173,7 @@ pub enum UserEmailVerificationState {
pub struct UserEmailVerification<T: StorageBackend> { pub struct UserEmailVerification<T: StorageBackend> {
pub data: T::UserEmailVerificationData, pub data: T::UserEmailVerificationData,
pub email: UserEmail<T>, pub email: UserEmail<T>,
pub code: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub state: UserEmailVerificationState, pub state: UserEmailVerificationState,
} }
@ -182,6 +183,7 @@ impl<S: StorageBackendMarker> From<UserEmailVerification<S>> for UserEmailVerifi
Self { Self {
data: (), data: (),
email: v.email.into(), email: v.email.into(),
code: v.code,
created_at: v.created_at, created_at: v.created_at,
state: v.state, state: v.state,
} }
@ -207,6 +209,7 @@ where
.flat_map(|state| { .flat_map(|state| {
UserEmail::samples().into_iter().map(move |email| Self { UserEmail::samples().into_iter().map(move |email| Self {
data: Default::default(), data: Default::default(),
code: "123456".to_string(),
email, email,
created_at: Utc::now() - Duration::minutes(10), created_at: Utc::now() - Duration::minutes(10),
state: state.clone(), state: state.clone(),

View File

@ -71,10 +71,14 @@ impl Mailer {
let multipart = MultiPart::alternative_plain_html(plain, html); let multipart = MultiPart::alternative_plain_html(plain, html);
let subject = self
.templates
.render_email_verification_subject(context)
.await?;
let message = self let message = self
.base_message() .base_message()
// TODO: template/localize this .subject(subject.trim())
.subject("Verify your email address")
.to(to) .to(to)
.multipart(multipart)?; .multipart(multipart)?;

View File

@ -159,8 +159,9 @@ where
get(self::views::register::get).post(self::views::register::post), get(self::views::register::get).post(self::views::register::post),
) )
.route( .route(
mas_router::VerifyEmail::route(), mas_router::AccountVerifyEmail::route(),
get(self::views::verify::get), 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::Account::route(), get(self::views::account::get))
.route( .route(

View File

@ -25,7 +25,7 @@ use mas_axum_utils::{
use mas_config::Encrypter; use mas_config::Encrypter;
use mas_data_model::{BrowserSession, User, UserEmail}; use mas_data_model::{BrowserSession, User, UserEmail};
use mas_email::Mailer; use mas_email::Mailer;
use mas_router::{Route, UrlBuilder}; use mas_router::Route;
use mas_storage::{ use mas_storage::{
user::{ user::{
add_user_email, add_user_email_verification_code, get_user_email, get_user_emails, add_user_email, add_user_email_verification_code, get_user_email, get_user_emails,
@ -34,16 +34,17 @@ use mas_storage::{
PostgresqlBackend, PostgresqlBackend,
}; };
use mas_templates::{AccountEmailsContext, EmailVerificationContext, TemplateContext, Templates}; use mas_templates::{AccountEmailsContext, EmailVerificationContext, TemplateContext, Templates};
use rand::{distributions::Alphanumeric, thread_rng, Rng}; use rand::{distributions::Uniform, thread_rng, Rng};
use serde::Deserialize; use serde::Deserialize;
use sqlx::{PgExecutor, PgPool}; use sqlx::{PgExecutor, PgPool};
use tracing::info; use tracing::info;
pub mod verify;
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
#[serde(tag = "action", rename_all = "snake_case")] #[serde(tag = "action", rename_all = "snake_case")]
pub enum ManagementForm { pub enum ManagementForm {
Add { email: String }, Add { email: String },
ResendConfirmation { data: String },
SetPrimary { data: String }, SetPrimary { data: String },
Remove { data: String }, Remove { data: String },
} }
@ -88,39 +89,35 @@ async fn render(
async fn start_email_verification( async fn start_email_verification(
mailer: &Mailer, mailer: &Mailer,
url_builder: &UrlBuilder,
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
user: &User<PostgresqlBackend>, user: &User<PostgresqlBackend>,
user_email: &UserEmail<PostgresqlBackend>, user_email: UserEmail<PostgresqlBackend>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
// First, generate a code // First, generate a code
let code: String = thread_rng() let range = Uniform::<u32>::from(0..1_000_000);
.sample_iter(&Alphanumeric) let code = thread_rng().sample(range).to_string();
.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 address: Address = user_email.email.parse()?;
let verification = add_user_email_verification_code(executor, user_email, code).await?;
// And send the verification email
let mailbox = Mailbox::new(Some(user.username.clone()), address); let mailbox = Mailbox::new(Some(user.username.clone()), address);
let link = url_builder.email_verification(code); let context = EmailVerificationContext::new(user.clone().into(), verification.clone().into());
let context = EmailVerificationContext::new(user.clone().into(), link);
mailer.send_verification_email(mailbox, &context).await?; mailer.send_verification_email(mailbox, &context).await?;
info!(email.id = user_email.data, "Verification email sent"); info!(
email.id = verification.email.data,
"Verification email sent"
);
Ok(()) Ok(())
} }
pub(crate) async fn post( pub(crate) async fn post(
Extension(templates): Extension<Templates>, Extension(templates): Extension<Templates>,
Extension(pool): Extension<PgPool>, Extension(pool): Extension<PgPool>,
Extension(url_builder): Extension<UrlBuilder>,
Extension(mailer): Extension<Mailer>, Extension(mailer): Extension<Mailer>,
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
Form(form): Form<ProtectedForm<ManagementForm>>, Form(form): Form<ProtectedForm<ManagementForm>>,
@ -143,8 +140,10 @@ pub(crate) async fn post(
match form { match form {
ManagementForm::Add { email } => { ManagementForm::Add { email } => {
let user_email = add_user_email(&mut txn, &session.user, email).await?; let user_email = add_user_email(&mut txn, &session.user, email).await?;
start_email_verification(&mailer, &url_builder, &mut txn, &session.user, &user_email) let next = mas_router::AccountVerifyEmail(user_email.data);
.await?; start_email_verification(&mailer, &mut txn, &session.user, user_email).await?;
txn.commit().await?;
return Ok((cookie_jar, next.go()).into_response());
} }
ManagementForm::Remove { data } => { ManagementForm::Remove { data } => {
let id = data.parse()?; let id = data.parse()?;
@ -152,14 +151,6 @@ pub(crate) async fn post(
let email = get_user_email(&mut txn, &session.user, id).await?; let email = get_user_email(&mut txn, &session.user, id).await?;
remove_user_email(&mut txn, email).await?; remove_user_email(&mut txn, email).await?;
} }
ManagementForm::ResendConfirmation { data } => {
let id = data.parse()?;
let user_email = get_user_email(&mut txn, &session.user, id).await?;
start_email_verification(&mailer, &url_builder, &mut txn, &session.user, &user_email)
.await?;
}
ManagementForm::SetPrimary { data } => { ManagementForm::SetPrimary { data } => {
let id = data.parse()?; let id = data.parse()?;
let email = get_user_email(&mut txn, &session.user, id).await?; let email = get_user_email(&mut txn, &session.user, id).await?;

View File

@ -0,0 +1,119 @@
// 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, Path, Query},
response::{Html, IntoResponse, Response},
};
use axum_extra::extract::PrivateCookieJar;
use chrono::Duration;
use mas_axum_utils::{
csrf::{CsrfExt, ProtectedForm},
FancyError, SessionInfoExt,
};
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,
};
use mas_templates::{EmailVerificationPageContext, TemplateContext, Templates};
use serde::Deserialize;
use sqlx::PgPool;
use crate::views::shared::OptionalPostAuthAction;
#[derive(Deserialize, Debug)]
pub struct CodeForm {
code: String,
}
pub(crate) async fn get(
Extension(templates): Extension<Templates>,
Extension(pool): Extension<PgPool>,
Query(query): Query<OptionalPostAuthAction>,
Path(id): Path<i64>,
cookie_jar: PrivateCookieJar<Encrypter>,
) -> Result<Response, FancyError> {
let mut txn = 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 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 = lookup_user_email_by_id(&mut txn, &session.user, id).await?;
if user_email.confirmed_at.is_some() {
// This email was already verified, skip
let destination = query.go_next_or_default(&mas_router::AccountEmails);
return Ok((cookie_jar, destination).into_response());
}
let ctx = EmailVerificationPageContext::new(user_email)
.with_session(session)
.with_csrf(csrf_token.form_value());
let content = templates.render_email_verification_form(&ctx).await?;
txn.commit().await?;
Ok((cookie_jar, Html(content)).into_response())
}
pub(crate) async fn post(
Extension(pool): Extension<PgPool>,
cookie_jar: PrivateCookieJar<Encrypter>,
Query(query): Query<OptionalPostAuthAction>,
Path(id): Path<i64>,
Form(form): Form<ProtectedForm<CodeForm>>,
) -> Result<Response, FancyError> {
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 email = lookup_user_email_by_id(&mut txn, &session.user, id).await?;
// TODO: make those 8 hours configurable
let verification =
lookup_user_email_verification_code(&mut txn, email, &form.code, Duration::hours(8))
.await?;
// TODO: display nice errors if the code was already consumed or expired
let verification = consume_email_verification(&mut txn, verification).await?;
let _email = mark_user_email_as_verified(&mut txn, verification.email).await?;
txn.commit().await?;
let destination = query.go_next_or_default(&mas_router::AccountEmails);
Ok((cookie_jar, destination).into_response())
}

View File

@ -19,4 +19,3 @@ pub mod logout;
pub mod reauth; pub mod reauth;
pub mod register; pub mod register;
pub mod shared; pub mod shared;
pub mod verify;

View File

@ -27,12 +27,16 @@ pub(crate) struct OptionalPostAuthAction {
} }
impl OptionalPostAuthAction { impl OptionalPostAuthAction {
pub fn go_next(&self) -> axum::response::Redirect { pub fn go_next_or_default<T: Route>(&self, default: &T) -> axum::response::Redirect {
self.post_auth_action.as_ref().map_or_else( self.post_auth_action
|| mas_router::Index.go(), .as_ref()
mas_router::PostAuthAction::go_next, .map_or_else(|| default.go(), mas_router::PostAuthAction::go_next)
)
} }
pub fn go_next(&self) -> axum::response::Redirect {
self.go_next_or_default(&mas_router::Index)
}
pub async fn load_context<'e>( pub async fn load_context<'e>(
&self, &self,
conn: &mut PgConnection, conn: &mut PgConnection,

View File

@ -1,60 +0,0 @@
// 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, Path},
response::{Html, IntoResponse},
};
use axum_extra::extract::PrivateCookieJar;
use chrono::Duration;
use mas_axum_utils::{csrf::CsrfExt, FancyError, SessionInfoExt};
use mas_config::Encrypter;
use mas_storage::user::{
consume_email_verification, lookup_user_email_verification_code, mark_user_email_as_verified,
};
use mas_templates::{EmptyContext, TemplateContext, Templates};
use sqlx::PgPool;
pub(crate) async fn get(
Extension(templates): Extension<Templates>,
Extension(pool): Extension<PgPool>,
Path(code): Path<String>,
cookie_jar: PrivateCookieJar<Encrypter>,
) -> Result<impl IntoResponse, FancyError> {
let mut txn = pool.begin().await?;
// TODO: make those 8 hours configurable
let verification =
lookup_user_email_verification_code(&mut txn, &code, Duration::hours(8)).await?;
// TODO: display nice errors if the code was already consumed or expired
let verification = consume_email_verification(&mut txn, verification).await?;
let _email = mark_user_email_as_verified(&mut txn, verification.email).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 ctx = EmptyContext
.maybe_with_session(maybe_session)
.with_csrf(csrf_token.form_value());
let content = templates.render_email_verification_done(&ctx).await?;
txn.commit().await?;
Ok((cookie_jar, Html(content)))
}

View File

@ -52,7 +52,7 @@ impl PostAuthAction {
} }
/// `GET /.well-known/openid-configuration` /// `GET /.well-known/openid-configuration`
#[derive(Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct OidcConfiguration; pub struct OidcConfiguration;
impl SimpleRoute for OidcConfiguration { impl SimpleRoute for OidcConfiguration {
@ -60,7 +60,7 @@ impl SimpleRoute for OidcConfiguration {
} }
/// `GET /.well-known/webfinger` /// `GET /.well-known/webfinger`
#[derive(Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct Webfinger; pub struct Webfinger;
impl SimpleRoute for Webfinger { impl SimpleRoute for Webfinger {
@ -75,7 +75,7 @@ impl SimpleRoute for ChangePasswordDiscovery {
} }
/// `GET /oauth2/keys.json` /// `GET /oauth2/keys.json`
#[derive(Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct OAuth2Keys; pub struct OAuth2Keys;
impl SimpleRoute for OAuth2Keys { impl SimpleRoute for OAuth2Keys {
@ -83,7 +83,7 @@ impl SimpleRoute for OAuth2Keys {
} }
/// `GET /oauth2/userinfo` /// `GET /oauth2/userinfo`
#[derive(Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct OidcUserinfo; pub struct OidcUserinfo;
impl SimpleRoute for OidcUserinfo { impl SimpleRoute for OidcUserinfo {
@ -91,7 +91,7 @@ impl SimpleRoute for OidcUserinfo {
} }
/// `POST /oauth2/userinfo` /// `POST /oauth2/userinfo`
#[derive(Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct OAuth2Introspection; pub struct OAuth2Introspection;
impl SimpleRoute for OAuth2Introspection { impl SimpleRoute for OAuth2Introspection {
@ -99,7 +99,7 @@ impl SimpleRoute for OAuth2Introspection {
} }
/// `POST /oauth2/token` /// `POST /oauth2/token`
#[derive(Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct OAuth2TokenEndpoint; pub struct OAuth2TokenEndpoint;
impl SimpleRoute for OAuth2TokenEndpoint { impl SimpleRoute for OAuth2TokenEndpoint {
@ -107,7 +107,7 @@ impl SimpleRoute for OAuth2TokenEndpoint {
} }
/// `POST /oauth2/registration` /// `POST /oauth2/registration`
#[derive(Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct OAuth2RegistrationEndpoint; pub struct OAuth2RegistrationEndpoint;
impl SimpleRoute for OAuth2RegistrationEndpoint { impl SimpleRoute for OAuth2RegistrationEndpoint {
@ -115,7 +115,7 @@ impl SimpleRoute for OAuth2RegistrationEndpoint {
} }
/// `GET /authorize` /// `GET /authorize`
#[derive(Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct OAuth2AuthorizationEndpoint; pub struct OAuth2AuthorizationEndpoint;
impl SimpleRoute for OAuth2AuthorizationEndpoint { impl SimpleRoute for OAuth2AuthorizationEndpoint {
@ -123,7 +123,7 @@ impl SimpleRoute for OAuth2AuthorizationEndpoint {
} }
/// `GET /` /// `GET /`
#[derive(Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct Index; pub struct Index;
impl SimpleRoute for Index { impl SimpleRoute for Index {
@ -131,7 +131,7 @@ impl SimpleRoute for Index {
} }
/// `GET /health` /// `GET /health`
#[derive(Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct Healthcheck; pub struct Healthcheck;
impl SimpleRoute for Healthcheck { impl SimpleRoute for Healthcheck {
@ -200,7 +200,7 @@ impl From<Option<PostAuthAction>> for Login {
} }
/// `POST /logout` /// `POST /logout`
#[derive(Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct Logout; pub struct Logout;
impl SimpleRoute for Logout { impl SimpleRoute for Logout {
@ -315,23 +315,23 @@ impl From<Option<PostAuthAction>> for Register {
} }
} }
/// `GET /verify/:code` /// `GET /account/emails/verify/:id`
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct VerifyEmail(pub String); pub struct AccountVerifyEmail(pub i64);
impl Route for VerifyEmail { impl Route for AccountVerifyEmail {
type Query = (); type Query = ();
fn route() -> &'static str { fn route() -> &'static str {
"/verify/:code" "/account/emails/verify/:id"
} }
fn path(&self) -> std::borrow::Cow<'static, str> { fn path(&self) -> std::borrow::Cow<'static, str> {
format!("/verify/{}", self.0).into() format!("/account/emails/verify/{}", self.0).into()
} }
} }
/// `GET /account` /// `GET /account`
#[derive(Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct Account; pub struct Account;
impl SimpleRoute for Account { impl SimpleRoute for Account {
@ -339,7 +339,7 @@ impl SimpleRoute for Account {
} }
/// `GET|POST /account/password` /// `GET|POST /account/password`
#[derive(Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct AccountPassword; pub struct AccountPassword;
impl SimpleRoute for AccountPassword { impl SimpleRoute for AccountPassword {
@ -347,7 +347,7 @@ impl SimpleRoute for AccountPassword {
} }
/// `GET|POST /account/emails` /// `GET|POST /account/emails`
#[derive(Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct AccountEmails; pub struct AccountEmails;
impl SimpleRoute for AccountEmails { impl SimpleRoute for AccountEmails {

View File

@ -91,25 +91,4 @@ impl UrlBuilder {
pub fn jwks_uri(&self) -> Url { pub fn jwks_uri(&self) -> Url {
self.url_for(&crate::endpoints::OAuth2Keys) self.url_for(&crate::endpoints::OAuth2Keys)
} }
/// Email verification URL
#[must_use]
pub fn email_verification(&self, code: String) -> Url {
self.url_for(&crate::endpoints::VerifyEmail(code))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_email_verification_url() {
let base = Url::parse("https://example.com/").unwrap();
let builder = UrlBuilder::new(base);
assert_eq!(
builder.email_verification("123456abcdef".into()).as_str(),
"https://example.com/verify/123456abcdef"
);
}
} }

View File

@ -669,6 +669,36 @@ pub async fn lookup_user_email(
Ok(res.into()) Ok(res.into())
} }
#[tracing::instrument(skip(executor))]
pub async fn lookup_user_email_by_id(
executor: impl PgExecutor<'_>,
user: &User<PostgresqlBackend>,
id: i64,
) -> anyhow::Result<UserEmail<PostgresqlBackend>> {
let res = sqlx::query_as!(
UserEmailLookup,
r#"
SELECT
ue.id AS "user_email_id",
ue.email AS "user_email",
ue.created_at AS "user_email_created_at",
ue.confirmed_at AS "user_email_confirmed_at"
FROM user_emails ue
WHERE ue.user_id = $1
AND ue.id = $2
"#,
user.data,
id,
)
.fetch_one(executor)
.instrument(info_span!("Lookup user email"))
.await
.context("could not lookup user email")?;
Ok(res.into())
}
#[tracing::instrument(skip(executor))] #[tracing::instrument(skip(executor))]
pub async fn mark_user_email_as_verified( pub async fn mark_user_email_as_verified(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
@ -695,18 +725,16 @@ pub async fn mark_user_email_as_verified(
struct UserEmailVerificationLookup { struct UserEmailVerificationLookup {
verification_id: i64, verification_id: i64,
verification_code: String,
verification_expired: bool, verification_expired: bool,
verification_created_at: DateTime<Utc>, verification_created_at: DateTime<Utc>,
verification_consumed_at: Option<DateTime<Utc>>, verification_consumed_at: Option<DateTime<Utc>>,
user_email_id: i64,
user_email: String,
user_email_created_at: DateTime<Utc>,
user_email_confirmed_at: Option<DateTime<Utc>>,
} }
#[tracing::instrument(skip(executor))] #[tracing::instrument(skip(executor))]
pub async fn lookup_user_email_verification_code( pub async fn lookup_user_email_verification_code(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
email: UserEmail<PostgresqlBackend>,
code: &str, code: &str,
max_age: chrono::Duration, max_age: chrono::Duration,
) -> anyhow::Result<UserEmailVerification<PostgresqlBackend>> { ) -> anyhow::Result<UserEmailVerification<PostgresqlBackend>> {
@ -720,19 +748,16 @@ pub async fn lookup_user_email_verification_code(
r#" r#"
SELECT SELECT
ev.id AS "verification_id", ev.id AS "verification_id",
(ev.created_at + $2 < NOW()) AS "verification_expired!", ev.code AS "verification_code",
(ev.created_at + $3 < NOW()) AS "verification_expired!",
ev.created_at AS "verification_created_at", ev.created_at AS "verification_created_at",
ev.consumed_at AS "verification_consumed_at", ev.consumed_at AS "verification_consumed_at"
ue.id AS "user_email_id",
ue.email AS "user_email",
ue.created_at AS "user_email_created_at",
ue.confirmed_at AS "user_email_confirmed_at"
FROM user_email_verifications ev FROM user_email_verifications ev
INNER JOIN user_emails ue
ON ue.id = ev.user_email_id
WHERE ev.code = $1 WHERE ev.code = $1
AND ev.user_email_id = $2
"#, "#,
code, code,
email.data,
max_age, max_age,
) )
.fetch_one(executor) .fetch_one(executor)
@ -740,13 +765,6 @@ pub async fn lookup_user_email_verification_code(
.await .await
.context("could not lookup user email verification")?; .context("could not lookup user email verification")?;
let email = UserEmail {
data: res.user_email_id,
email: res.user_email,
created_at: res.user_email_created_at,
confirmed_at: res.user_email_confirmed_at,
};
let state = if res.verification_expired { let state = if res.verification_expired {
UserEmailVerificationState::Expired UserEmailVerificationState::Expired
} else if let Some(when) = res.verification_consumed_at { } else if let Some(when) = res.verification_consumed_at {
@ -757,6 +775,7 @@ pub async fn lookup_user_email_verification_code(
Ok(UserEmailVerification { Ok(UserEmailVerification {
data: res.verification_id, data: res.verification_id,
code: res.verification_code,
email, email,
state, state,
created_at: res.verification_created_at, created_at: res.verification_created_at,
@ -794,21 +813,31 @@ pub async fn consume_email_verification(
#[tracing::instrument(skip(executor, email), fields(email.id = email.data, %email.email))] #[tracing::instrument(skip(executor, email), fields(email.id = email.data, %email.email))]
pub async fn add_user_email_verification_code( pub async fn add_user_email_verification_code(
executor: impl PgExecutor<'_>, executor: impl PgExecutor<'_>,
email: &UserEmail<PostgresqlBackend>, email: UserEmail<PostgresqlBackend>,
code: &str, code: String,
) -> anyhow::Result<()> { ) -> anyhow::Result<UserEmailVerification<PostgresqlBackend>> {
sqlx::query!( let res = sqlx::query_as!(
IdAndCreationTime,
r#" r#"
INSERT INTO user_email_verifications (user_email_id, code) INSERT INTO user_email_verifications (user_email_id, code)
VALUES ($1, $2) VALUES ($1, $2)
RETURNING id, created_at
"#, "#,
email.data, email.data,
code, code,
) )
.execute(executor) .fetch_one(executor)
.instrument(info_span!("Add user email verification code")) .instrument(info_span!("Add user email verification code"))
.await .await
.context("could not insert user email verification code")?; .context("could not insert user email verification code")?;
Ok(()) let verification = UserEmailVerification {
data: res.id,
email,
code,
created_at: res.created_at,
state: UserEmailVerificationState::Valid,
};
Ok(verification)
} }

View File

@ -19,7 +19,7 @@
use chrono::Utc; use chrono::Utc;
use mas_data_model::{ use mas_data_model::{
AuthorizationGrant, BrowserSession, CompatSsoLogin, CompatSsoLoginState, StorageBackend, User, AuthorizationGrant, BrowserSession, CompatSsoLogin, CompatSsoLoginState, StorageBackend, User,
UserEmail, UserEmail, UserEmailVerification,
}; };
use mas_router::PostAuthAction; use mas_router::PostAuthAction;
use serde::{ser::SerializeStruct, Deserialize, Serialize}; use serde::{ser::SerializeStruct, Deserialize, Serialize};
@ -562,21 +562,18 @@ impl<T: StorageBackend> TemplateContext for AccountEmailsContext<T> {
} }
} }
/// Context used by the `emails/verification.{txt,html}` templates /// Context used by the `emails/verification.{txt,html,subject}` templates
#[derive(Serialize)] #[derive(Serialize)]
pub struct EmailVerificationContext { pub struct EmailVerificationContext {
user: User<()>, user: User<()>,
verification_link: Url, verification: UserEmailVerification<()>,
} }
impl EmailVerificationContext { impl EmailVerificationContext {
/// Constructs a context for the verification email /// Constructs a context for the verification email
#[must_use] #[must_use]
pub fn new(user: User<()>, verification_link: Url) -> Self { pub fn new(user: User<()>, verification: UserEmailVerification<()>) -> Self {
Self { Self { user, verification }
user,
verification_link,
}
} }
} }
@ -587,16 +584,84 @@ impl TemplateContext for EmailVerificationContext {
{ {
User::samples() User::samples()
.into_iter() .into_iter()
.map(|u| { .map(|user| {
Self::new( let email = UserEmail {
u, data: (),
Url::parse("https://example.com/emails/verify?code=2134").unwrap(), email: "foobar@example.com".to_string(),
) created_at: Utc::now(),
confirmed_at: None,
};
let verification = UserEmailVerification {
data: (),
code: "123456".to_string(),
email,
created_at: Utc::now(),
state: mas_data_model::UserEmailVerificationState::Valid,
};
Self { user, verification }
}) })
.collect() .collect()
} }
} }
/// Fields of the email verification form
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EmailVerificationFormField {
/// The code field
Code,
}
impl FormField for EmailVerificationFormField {
fn keep(&self) -> bool {
match self {
Self::Code => true,
}
}
}
/// Context used by the `pages/account/verify.html` templates
#[derive(Serialize)]
pub struct EmailVerificationPageContext {
form: FormState<EmailVerificationFormField>,
email: UserEmail<()>,
}
impl EmailVerificationPageContext {
/// Constructs a context for the email verification page
#[must_use]
pub fn new<T>(email: T) -> Self
where
T: Into<UserEmail<()>>,
{
Self {
form: FormState::default(),
email: email.into(),
}
}
}
impl TemplateContext for EmailVerificationPageContext {
fn sample() -> Vec<Self>
where
Self: Sized,
{
let email = UserEmail {
data: (),
email: "foobar@example.com".to_string(),
created_at: Utc::now(),
confirmed_at: None,
};
vec![Self {
form: FormState::default(),
email,
}]
}
}
/// Context used by the `form_post.html` template /// Context used by the `form_post.html` template
#[derive(Serialize)] #[derive(Serialize)]
pub struct FormPostContext<T> { pub struct FormPostContext<T> {

View File

@ -46,10 +46,10 @@ mod macros;
pub use self::{ pub use self::{
context::{ context::{
AccountContext, AccountEmailsContext, CompatSsoContext, ConsentContext, AccountContext, AccountEmailsContext, CompatSsoContext, ConsentContext,
EmailVerificationContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, EmailVerificationContext, EmailVerificationPageContext, EmptyContext, ErrorContext,
LoginContext, LoginFormField, PostAuthContext, ReauthContext, ReauthFormField, FormPostContext, IndexContext, LoginContext, LoginFormField, PostAuthContext,
RegisterContext, RegisterFormField, TemplateContext, WithCsrf, WithOptionalSession, ReauthContext, ReauthFormField, RegisterContext, RegisterFormField, TemplateContext,
WithSession, WithCsrf, WithOptionalSession, WithSession,
}, },
forms::{FieldError, FormError, FormField, FormState, ToFormState}, forms::{FieldError, FormError, FormField, FormState, ToFormState},
}; };
@ -154,7 +154,7 @@ impl Templates {
// This uses blocking I/Os, do that in a blocking task // This uses blocking I/Os, do that in a blocking task
let tera = tokio::task::spawn_blocking(move || { let tera = tokio::task::spawn_blocking(move || {
// Using `to_string_lossy` here is probably fine // Using `to_string_lossy` here is probably fine
let path = format!("{}/**/*.{{html,txt}}", root.to_string_lossy()); let path = format!("{}/**/*.{{html,txt,subject}}", root.to_string_lossy());
info!(%path, "Loading templates from filesystem"); info!(%path, "Loading templates from filesystem");
Tera::parse(&path) Tera::parse(&path)
}) })
@ -326,11 +326,14 @@ register_templates! {
/// Render the email verification email (plain text variant) /// Render the email verification email (plain text variant)
pub fn render_email_verification_txt(EmailVerificationContext) { "emails/verification.txt" } pub fn render_email_verification_txt(EmailVerificationContext) { "emails/verification.txt" }
/// Render the email verification email (plain text variant) /// Render the email verification email (HTML text variant)
pub fn render_email_verification_html(EmailVerificationContext) { "emails/verification.html" } pub fn render_email_verification_html(EmailVerificationContext) { "emails/verification.html" }
/// Render the email verification subject
pub fn render_email_verification_subject(EmailVerificationContext) { "emails/verification.subject" }
/// Render the email post-email verification page /// Render the email post-email verification page
pub fn render_email_verification_done(WithCsrf<WithOptionalSession<EmptyContext>>) { "pages/verify.html" } pub fn render_email_verification_form(WithCsrf<WithSession<EmailVerificationPageContext>>) { "pages/account/verify.html" }
} }
impl Templates { impl Templates {
@ -340,6 +343,7 @@ impl Templates {
check::render_login(self).await?; check::render_login(self).await?;
check::render_register(self).await?; check::render_register(self).await?;
check::render_consent(self).await?; check::render_consent(self).await?;
check::render_sso_login(self).await?;
check::render_index(self).await?; check::render_index(self).await?;
check::render_account_index(self).await?; check::render_account_index(self).await?;
check::render_account_password(self).await?; check::render_account_password(self).await?;
@ -349,7 +353,8 @@ impl Templates {
check::render_error(self).await?; check::render_error(self).await?;
check::render_email_verification_txt(self).await?; check::render_email_verification_txt(self).await?;
check::render_email_verification_html(self).await?; check::render_email_verification_html(self).await?;
check::render_email_verification_done(self).await?; check::render_email_verification_subject(self).await?;
check::render_email_verification_form(self).await?;
Ok(()) Ok(())
} }
} }

View File

@ -5,7 +5,7 @@ Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
You may obtain a copy of the License at You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, distributed under the License is distributed on an "AS IS" BASIS,
@ -14,44 +14,44 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
#} #}
{% macro input(label, name, type="text", form_state=false, autocomplete=false, class="") %} {% macro input(label, name, type="text", form_state=false, autocomplete=false, class="", inputmode="text") %}
{% if not form_state %} {% if not form_state %}
{% set form_state = dict(errors=[], fields=dict()) %} {% set form_state = dict(errors=[], fields=dict()) %}
{% endif %} {% endif %}
{% set state = form_state.fields[name] | default(value=dict(errors=[], value="")) %} {% set state = form_state.fields[name] | default(value=dict(errors=[], value="")) %}
{% if state.errors is not empty %}
{% set border_color = "border-alert" %}
{% set text_color = "text-alert" %}
{% else %}
{% set border_color = "border-grey-50 dark:border-grey-450" %}
{% set text_color = "text-black-800 dark:text-grey-300" %}
{% endif %}
<label class="flex flex-col block {{ class }}">
<div
class="mx-2 -mb-3 -mt-2 leading-5 px-1 z-10 self-start bg-white dark:bg-black-900 border-white border-1 dark:border-2 dark:border-black-900 rounded-full text-sm {{ text_color }}">
{{ label }}</div>
<input name="{{ name }}"
class="z-0 px-3 py-2 bg-white dark:bg-black-900 rounded-lg {{ border_color }} border-1 dark:border-2 focus:border-accent focus:ring-0 focus:outline-0"
type="{{ type }}" inputmode="{{ inputmode }}" {% if autocomplete %} autocomplete="{{ autocomplete }}" {% endif %} {%
if state.value %} value="{{ state.value }}" {% endif %} />
{% if state.errors is not empty %} {% if state.errors is not empty %}
{% set border_color = "border-alert" %} {% for error in state.errors %}
{% set text_color = "text-alert" %} {% if error.kind != "unspecified" %}
{% else %} <div class="mx-4 text-sm text-alert">
{% set border_color = "border-grey-50 dark:border-grey-450" %} {% if error.kind == "required" %}
{% set text_color = "text-black-800 dark:text-grey-300" %} This field is required
{% endif %} {% elif error.kind == "exists" and name == "username" %}
This username is already taken
<label class="flex flex-col block {{ class }}"> {% else %}
<div class="mx-2 -mb-3 -mt-2 leading-5 px-1 z-10 self-start bg-white dark:bg-black-900 border-white border-1 dark:border-2 dark:border-black-900 rounded-full text-sm {{ text_color }}">{{ label }}</div> {{ error.kind }}
<input name="{{ name }}"
class="z-0 px-3 py-2 bg-white dark:bg-black-900 rounded-lg {{ border_color }} border-1 dark:border-2 focus:border-accent focus:ring-0 focus:outline-0"
type="{{ type }}"
{% if autocomplete %} autocomplete="{{ autocomplete }}" {% endif %}
{% if state.value %} value="{{ state.value }}" {% endif %}
/>
{% if state.errors is not empty %}
{% for error in state.errors %}
{% if error.kind != "unspecified" %}
<div class="mx-4 text-sm text-alert">
{% if error.kind == "required" %}
This field is required
{% elif error.kind == "exists" and name == "username" %}
This username is already taken
{% else %}
{{ error.kind }}
{% endif %}
</div>
{% endif %}
{% endfor %}
{% endif %} {% endif %}
</label> </div>
{% endif %}
{% endfor %}
{% endif %}
</label>
{% endmacro input %} {% endmacro input %}

View File

@ -1,5 +1,5 @@
{# {#
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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,8 +16,8 @@ limitations under the License.
Hi <b>{{ user.username }}</b>,<br /> Hi <b>{{ user.username }}</b>,<br />
<br /> <br />
click this link to verify your account:<br /> your email verification code is:
<br /> <br />
<a href="{{ verification_link }}">{{ verification_link }}</a><br /> <strong>{{ verification.code }}</strong><br />
<br /> <br />
kthxbye kthxbye

View File

@ -14,12 +14,4 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
#} #}
{% extends "base.html" %} Your auth service verification code is: {{ verification.code }}
{% block content %}
<section class="flex items-center justify-center flex-1">
<p class="font-bold text-xl">Email verified!</p>
</section>
{% endblock content %}

View File

@ -1,5 +1,5 @@
{# {#
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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,8 +16,8 @@ limitations under the License.
Hi {{ user.username }}, Hi {{ user.username }},
click this link to verify your account: your email verification code is:
<{{ verification_link }}> {{ verification.code }}
kthxbye kthxbye

View File

@ -0,0 +1,42 @@
{#
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() }}
<section class="flex items-center justify-center flex-1">
<form method="POST" class="grid grid-cols-1 gap-6 w-96 m-2">
<div class="text-center">
<h1 class="text-lg text-center font-medium">Email verification</h1>
<p>Please enter the 6-digit code sent to: <span class="font-bold">{{ email.email }}</span></p>
</div>
{% if form.errors is not empty %}
{% for error in form.errors %}
<div class="text-alert font-medium">
{{ errors::form_error_message(error=error) }}
</div>
{% endfor %}
{% endif %}
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{{ field::input(label="Code", name="code", form_state=form, autocomplete="one-time-code", inputmode="numeric") }}
{{ button::button(text="Submit") }}
</section>
{% endblock content %}