diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index ec7edee8..b9c27387 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -33,5 +33,8 @@ pub use self::{ }, tokens::{AccessToken, RefreshToken, TokenFormatError, TokenType}, traits::{StorageBackend, StorageBackendMarker}, - users::{Authentication, BrowserSession, User, UserEmail}, + users::{ + Authentication, BrowserSession, User, UserEmail, UserEmailVerification, + UserEmailVerificationState, + }, }; diff --git a/crates/data-model/src/traits.rs b/crates/data-model/src/traits.rs index f3efa83a..d4376740 100644 --- a/crates/data-model/src/traits.rs +++ b/crates/data-model/src/traits.rs @@ -21,6 +21,12 @@ pub trait StorageBackendMarker: StorageBackend {} pub trait StorageBackend { type UserData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; type UserEmailData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; + type UserEmailVerificationData: Clone + + Debug + + PartialEq + + Serialize + + DeserializeOwned + + Default; type AuthenticationData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; type BrowserSessionData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; type ClientData: Clone + Debug + PartialEq + Serialize + DeserializeOwned + Default; @@ -40,4 +46,5 @@ impl StorageBackend for () { type SessionData = (); type UserData = (); type UserEmailData = (); + type UserEmailVerificationData = (); } diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index 4fd1ad5b..a184f352 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Duration, Utc}; use serde::Serialize; use crate::traits::{StorageBackend, StorageBackendMarker}; @@ -119,7 +119,7 @@ pub struct UserEmail { impl From> for UserEmail<()> { fn from(e: UserEmail) -> Self { - UserEmail { + Self { data: (), email: e.email, created_at: e.created_at, @@ -135,13 +135,13 @@ where #[must_use] pub fn samples() -> Vec { vec![ - UserEmail { + Self { data: T::UserEmailData::default(), email: "alice@example.com".to_string(), created_at: Utc::now(), confirmed_at: Some(Utc::now()), }, - UserEmail { + Self { data: T::UserEmailData::default(), email: "bob@example.com".to_string(), created_at: Utc::now(), @@ -150,3 +150,58 @@ where ] } } + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub enum UserEmailVerificationState { + AlreadyUsed { when: DateTime }, + Expired, + Valid, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(bound = "T: StorageBackend")] +pub struct UserEmailVerification { + pub data: T::UserEmailVerificationData, + pub email: UserEmail, + pub created_at: DateTime, + pub state: UserEmailVerificationState, +} + +impl From> for UserEmailVerification<()> { + fn from(v: UserEmailVerification) -> Self { + Self { + data: (), + email: v.email.into(), + created_at: v.created_at, + state: v.state, + } + } +} + +impl UserEmailVerification +where + T::UserEmailData: Default + Clone, +{ + #[must_use] + pub fn samples() -> Vec { + let states = [ + UserEmailVerificationState::AlreadyUsed { + when: Utc::now() - Duration::minutes(5), + }, + UserEmailVerificationState::Expired, + UserEmailVerificationState::Valid, + ]; + + states + .into_iter() + .flat_map(|state| { + UserEmail::samples().into_iter().map(move |email| Self { + data: Default::default(), + email, + created_at: Utc::now() - Duration::minutes(10), + state: state.clone(), + }) + }) + .collect() + } +} diff --git a/crates/storage/migrations/20220121145350_email_verification.down.sql b/crates/storage/migrations/20220121145350_email_verification.down.sql new file mode 100644 index 00000000..d948d06a --- /dev/null +++ b/crates/storage/migrations/20220121145350_email_verification.down.sql @@ -0,0 +1,15 @@ +-- Copyright 2021 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. + +DROP TABLE user_email_verifications; diff --git a/crates/storage/migrations/20220121145350_email_verification.up.sql b/crates/storage/migrations/20220121145350_email_verification.up.sql new file mode 100644 index 00000000..89acb821 --- /dev/null +++ b/crates/storage/migrations/20220121145350_email_verification.up.sql @@ -0,0 +1,21 @@ +-- 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. + +CREATE TABLE user_email_verifications ( + "id" BIGSERIAL PRIMARY KEY, + "user_email_id" BIGINT NOT NULL REFERENCES user_emails (id) ON DELETE CASCADE, + "code" TEXT UNIQUE NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "consumed_at" TIMESTAMP WITH TIME ZONE DEFAULT NULL +); diff --git a/crates/storage/sqlx-data.json b/crates/storage/sqlx-data.json index 29d6dc94..15637640 100644 --- a/crates/storage/sqlx-data.json +++ b/crates/storage/sqlx-data.json @@ -707,6 +707,19 @@ "nullable": [] } }, + "99a1504e3cf80fb4eaad40e8593ac722ba1da7ee29ae674fa9ffe37dffa8b361": { + "query": "\n INSERT INTO user_email_verifications (user_email_id, code)\n VALUES ($1, $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [] + } + }, "a09dfe1019110f2ec6eba0d35bafa467ab4b7980dd8b556826f03863f8edb0ab": { "query": "UPDATE user_sessions SET active = FALSE WHERE id = $1", "describe": { @@ -808,6 +821,89 @@ ] } }, + "ba431a27a4b256ceacb5724bd746424ed1f059e59ae1aa818fdd5f44c01d70a0": { + "query": "\n UPDATE user_email_verifications\n SET consumed_at = NOW()\n WHERE id = $1\n RETURNING consumed_at AS \"consumed_at!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "consumed_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + true + ] + } + }, + "bb5ad7b64a2901a0d94ec22e6b2e497c6d2748e644478a7345da94a61b4ed053": { + "query": "\n SELECT\n ev.id AS \"verification_id\",\n (ev.created_at + $2 < NOW()) AS \"verification_expired!\",\n ev.created_at AS \"verification_created_at\",\n ev.consumed_at AS \"verification_consumed_at\",\n ue.id AS \"user_email_id\",\n ue.email AS \"user_email\",\n ue.created_at AS \"user_email_created_at\",\n ue.confirmed_at AS \"user_email_confirmed_at\"\n FROM user_email_verifications ev\n INNER JOIN user_emails ue\n ON ue.id = ev.user_email_id\n WHERE ev.code = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "verification_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "verification_expired!", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "verification_created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "verification_consumed_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "user_email_id", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "user_email", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "user_email_created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "user_email_confirmed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Interval" + ] + }, + "nullable": [ + false, + null, + false, + true, + false, + false, + false, + true + ] + } + }, "c29e741474aacc91c0aacc028a9e7452a5327d5ce6d4b791bf20a2636069087e": { "query": "\n INSERT INTO oauth2_sessions\n (user_session_id, client_id, scope)\n SELECT\n $1,\n og.client_id,\n og.scope\n FROM\n oauth2_authorization_grants og\n WHERE\n og.id = $2\n RETURNING id, created_at\n ", "describe": { diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 7ef28451..f98e3291 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -39,6 +39,7 @@ impl StorageBackend for PostgresqlBackend { type SessionData = i64; type UserData = i64; type UserEmailData = i64; + type UserEmailVerificationData = i64; } impl StorageBackendMarker for PostgresqlBackend {} diff --git a/crates/storage/src/user.rs b/crates/storage/src/user.rs index da553c87..fee69c0d 100644 --- a/crates/storage/src/user.rs +++ b/crates/storage/src/user.rs @@ -14,13 +14,16 @@ use std::borrow::BorrowMut; -use anyhow::Context; +use anyhow::{bail, Context}; use argon2::Argon2; use chrono::{DateTime, Utc}; -use mas_data_model::{errors::HtmlError, Authentication, BrowserSession, User, UserEmail}; +use mas_data_model::{ + errors::HtmlError, Authentication, BrowserSession, User, UserEmail, UserEmailVerification, + UserEmailVerificationState, +}; use password_hash::{PasswordHash, PasswordHasher, SaltString}; use rand::rngs::OsRng; -use sqlx::{Acquire, PgExecutor, Postgres, Transaction}; +use sqlx::{postgres::types::PgInterval, Acquire, PgExecutor, Postgres, Transaction}; use thiserror::Error; use tokio::task; use tracing::{info_span, Instrument}; @@ -685,3 +688,123 @@ pub async fn mark_user_email_as_verified( Ok(email) } + +struct UserEmailVerificationLookup { + verification_id: i64, + verification_expired: bool, + verification_created_at: DateTime, + verification_consumed_at: Option>, + user_email_id: i64, + user_email: String, + user_email_created_at: DateTime, + user_email_confirmed_at: Option>, +} + +#[tracing::instrument(skip(executor))] +pub async fn lookup_user_email_verification_code( + executor: impl PgExecutor<'_>, + code: &str, + max_age: chrono::Duration, +) -> anyhow::Result> { + // For some reason, we need to convert the type first + let max_age = PgInterval::try_from(max_age) + // For some reason, this error type does not let me to just bubble up the error here + .map_err(|e| anyhow::anyhow!("failed to encode duration: {}", e))?; + + let res = sqlx::query_as!( + UserEmailVerificationLookup, + r#" + SELECT + ev.id AS "verification_id", + (ev.created_at + $2 < NOW()) AS "verification_expired!", + ev.created_at AS "verification_created_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 + INNER JOIN user_emails ue + ON ue.id = ev.user_email_id + WHERE ev.code = $1 + "#, + code, + max_age, + ) + .fetch_one(executor) + .instrument(info_span!("Lookup user email verification")) + .await + .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 { + UserEmailVerificationState::Expired + } else if let Some(when) = res.verification_consumed_at { + UserEmailVerificationState::AlreadyUsed { when } + } else { + UserEmailVerificationState::Valid + }; + + Ok(UserEmailVerification { + data: res.verification_id, + email, + state, + created_at: res.verification_created_at, + }) +} + +#[tracing::instrument(skip(executor))] +pub async fn consume_email_verification( + executor: impl PgExecutor<'_>, + mut verification: UserEmailVerification, +) -> anyhow::Result> { + if !matches!(verification.state, UserEmailVerificationState::Valid) { + bail!("user email verification in wrong state"); + } + + let consumed_at = sqlx::query_scalar!( + r#" + UPDATE user_email_verifications + SET consumed_at = NOW() + WHERE id = $1 + RETURNING consumed_at AS "consumed_at!" + "#, + verification.data, + ) + .fetch_one(executor) + .instrument(info_span!("Consume user email verification")) + .await + .context("could not update user email verification")?; + + verification.state = UserEmailVerificationState::AlreadyUsed { when: consumed_at }; + + Ok(verification) +} + +#[tracing::instrument(skip(executor, email), fields(email.id = email.data, %email.email))] +pub async fn add_user_email_verification_code( + executor: impl PgExecutor<'_>, + email: &UserEmail, + code: &str, +) -> anyhow::Result<()> { + sqlx::query!( + r#" + INSERT INTO user_email_verifications (user_email_id, code) + VALUES ($1, $2) + "#, + email.data, + code, + ) + .execute(executor) + .instrument(info_span!("Add user email verification code")) + .await + .context("could not insert user email verification code")?; + + Ok(()) +}