From 115712843eecb3a0a47804f041349d3a7870f332 Mon Sep 17 00:00:00 2001 From: Timshel Date: Thu, 18 Jun 2026 19:42:00 +0200 Subject: [PATCH 1/2] Sends email verification --- .env.template | 4 + .../down.sql | 2 + .../2026-06-18-120000_add_sends_emails/up.sql | 13 ++ .../down.sql | 2 + .../2026-06-18-120000_add_sends_emails/up.sql | 13 ++ .../down.sql | 2 + .../2026-06-18-120000_add_sends_emails/up.sql | 13 ++ playwright/tests/send.spec.ts | 89 +++++++++- playwright/tests/setups/2fa.ts | 2 +- src/api/core/sends.rs | 37 ++-- src/api/identity.rs | 4 + src/auth/send.rs | 47 +++++- src/config.rs | 4 + src/db/models/mod.rs | 2 +- src/db/models/send.rs | 159 ++++++++++++++---- src/db/schema.rs | 14 ++ src/mail.rs | 13 ++ src/main.rs | 7 + src/static/templates/email/sends_otp.hbs | 6 + src/static/templates/email/sends_otp.html.hbs | 16 ++ 20 files changed, 395 insertions(+), 54 deletions(-) create mode 100644 migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql create mode 100644 migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql create mode 100644 migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql create mode 100644 migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql create mode 100644 migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql create mode 100644 migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql create mode 100644 src/static/templates/email/sends_otp.hbs create mode 100644 src/static/templates/email/sends_otp.html.hbs diff --git a/.env.template b/.env.template index 0d922774..98981a2c 100644 --- a/.env.template +++ b/.env.template @@ -187,6 +187,10 @@ ## Cron schedule of the job that cleans sso auth from incomplete flow ## Defaults to daily (20 minutes after midnight). Set blank to disable this job. # PURGE_INCOMPLETE_SSO_AUTH="0 20 0 * * *" +# +## Cron schedule of the job that cleans expired sends email OTP +## Defaults to daily (22 minutes after midnight). Set blank to disable this job. +# PURGE_SENDS_OTP="0 22 0 * * *" ######################## ### General settings ### diff --git a/migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql b/migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql new file mode 100644 index 00000000..2cfff832 --- /dev/null +++ b/migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE sends DROP COLUMN emails; +DROP TABLE sends_otp; diff --git a/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql b/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql new file mode 100644 index 00000000..ae69e9d2 --- /dev/null +++ b/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql @@ -0,0 +1,13 @@ +ALTER TABLE sends ADD COLUMN emails TEXT; + +CREATE TABLE sends_otp ( + send_uuid CHAR(36) NOT NULL REFERENCES sends(uuid) ON DELETE CASCADE ON UPDATE CASCADE, + email VARCHAR(255) NOT NULL, + code TEXT NOT NULL, + + creation_date DATETIME NOT NULL, + revision_date DATETIME NOT NULL, + expiration_date DATETIME NOT NULL, + + PRIMARY KEY(send_uuid, email) +); diff --git a/migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql b/migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql new file mode 100644 index 00000000..2cfff832 --- /dev/null +++ b/migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE sends DROP COLUMN emails; +DROP TABLE sends_otp; diff --git a/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql b/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql new file mode 100644 index 00000000..bdbc4b95 --- /dev/null +++ b/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql @@ -0,0 +1,13 @@ +ALTER TABLE sends ADD COLUMN emails TEXT; + +CREATE TABLE sends_otp ( + send_uuid TEXT NOT NULL REFERENCES sends(uuid) ON DELETE CASCADE ON UPDATE CASCADE, + email TEXT NOT NULL, + code TEXT NOT NULL, + + creation_date TIMESTAMP NOT NULL, + revision_date TIMESTAMP NOT NULL, + expiration_date TIMESTAMP NOT NULL, + + PRIMARY KEY(send_uuid, email) +); diff --git a/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql b/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql new file mode 100644 index 00000000..2cfff832 --- /dev/null +++ b/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE sends DROP COLUMN emails; +DROP TABLE sends_otp; diff --git a/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql b/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql new file mode 100644 index 00000000..86ab7a45 --- /dev/null +++ b/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql @@ -0,0 +1,13 @@ +ALTER TABLE sends ADD COLUMN emails TEXT; + +CREATE TABLE sends_otp ( + send_uuid TEXT NOT NULL REFERENCES sends(uuid) ON DELETE CASCADE ON UPDATE CASCADE, + email TEXT NOT NULL, + code TEXT NOT NULL, + + creation_date DATETIME NOT NULL, + revision_date DATETIME NOT NULL, + expiration_date DATETIME NOT NULL, + + PRIMARY KEY(send_uuid, email) +); diff --git a/playwright/tests/send.spec.ts b/playwright/tests/send.spec.ts index d27c3ffc..f5927a09 100644 --- a/playwright/tests/send.spec.ts +++ b/playwright/tests/send.spec.ts @@ -1,21 +1,46 @@ import { test, expect, type Page, type TestInfo } from '@playwright/test'; import * as OTPAuth from "otpauth"; +import { MailDev } from 'maildev'; import * as utils from "../global-utils"; -import { createAccount } from './setups/user'; +import { createAccount, logUser } from './setups/user'; +import { retrieveEmailCode } from './setups/2fa'; let users = utils.loadEnv(); +let mailserver; + test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { - await utils.startVault(browser, testInfo, {}); + mailserver = new MailDev({ + port: process.env.MAILDEV_SMTP_PORT, + web: { port: process.env.MAILDEV_HTTP_PORT }, + }) + + await mailserver.listen(); + + await utils.startVault(browser, testInfo, { + SMTP_HOST: process.env.MAILDEV_HOST, + SMTP_FROM: process.env.PW_SMTP_FROM, + }); + + const context = await browser.newContext(); + const page = await context.newPage(); + await createAccount(test, page, users.user1); + await context.close(); }); test.afterAll('Teardown', async ({}) => { utils.stopVault(); + if( mailserver ){ + await mailserver.close(); + } }); test('Send', async ({ browser, page }) => { - await createAccount(test, page, users.user1); + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + + await logUser(test, page, users.user1); const send_url = await test.step('Create', async () => { await page.getByRole('link', { name: 'Send' }).click(); @@ -33,15 +58,21 @@ test('Send', async ({ browser, page }) => { return await page.evaluate(() => navigator.clipboard.readText()); }); - const context2 = await browser.newContext(); - const page2 = await context2.newPage(); - await test.step('View', async () => { await page2.goto(send_url, { waitUntil: 'domcontentloaded' }); await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible(); await expect(await page2.getByRole('paragraph').filter({ hasText: 'Test' })).toBeVisible(); }); + await context2.close(); +}); + +test('Password', async ({ browser, page }) => { + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + + await logUser(test, page, users.user1); + const pwd_url = await test.step('Create with password', async () => { await page.getByRole('link', { name: 'Send' }).click(); await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); @@ -69,4 +100,50 @@ test('Send', async ({ browser, page }) => { await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible(); await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible(); }); + + await context2.close(); +}); + +test('Validation', async ({ browser, page }) => { + const mailBuffer = mailserver.buffer(users.user2.email); + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + const pageMail = await context2.newPage(); + + await logUser(test, page, users.user1); + + const mail_url = await test.step('Create with email validation', async () => { + await page.getByRole('link', { name: 'Send' }).click(); + await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'New', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Text' }).click(); + + await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Email'); + await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('Email'); + await page.getByRole('combobox', { name: 'Who can view' }).click(); + await page.getByText('Specific people').click(); + await page.getByRole('textbox', { name: 'Emails (required)' }).fill(users.user2.email); + + await page.getByRole('button', { name: 'Save' }).click(); + await page.locator('footer').getByRole('button', { name: 'Copy link' }).click(); + + return await page.evaluate(() => navigator.clipboard.readText()); + }); + + await test.step('View with email OTP', async () => { + await page2.goto(mail_url, { waitUntil: 'domcontentloaded' }); + await expect(page2.getByRole('heading', { name: 'Verify your email to view' })).toBeVisible(); + await page2.getByRole('textbox', { name: 'Email (required)' }).fill(users.user2.email); + await page2.getByRole('button', { name: 'Send code' }).click(); + + let code = await retrieveEmailCode(test, pageMail, mailBuffer); + await page2.getByRole('textbox', { name: 'Verification code' }).fill(code); + await page2.getByRole('button', { name: 'View Send' }).click(); + await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible(); + await expect(await page2.getByRole('paragraph').filter({ hasText: 'Email' })).toBeVisible(); + }); + + mailBuffer.close(); + await context2.close(); }); diff --git a/playwright/tests/setups/2fa.ts b/playwright/tests/setups/2fa.ts index d7936420..56601112 100644 --- a/playwright/tests/setups/2fa.ts +++ b/playwright/tests/setups/2fa.ts @@ -65,7 +65,7 @@ export async function activateEmail(test: Test, page: Page, user: { name: string export async function retrieveEmailCode(test: Test, page: Page, mailBuffer: MailBuffer): string { return await test.step('retrieve code', async () => { - const codeMail = await mailBuffer.expect((mail) => mail.subject.includes("Login Verification Code")); + const codeMail = await mailBuffer.expect((mail) => mail.subject.includes("Verification Code")); const page2 = await page.context().newPage(); await page2.setContent(codeMail.html); const code = await page2.getByTestId("2fa").innerText(); diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index fb3ee48f..cafc29df 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -151,21 +151,29 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { ); } - if data.emails.is_some() { - err!("Sends with email verification is not supported"); + if !CONFIG.mail_enabled() && data.emails.is_some() { + err!("Email are disabled, cannot send verification code"); } - let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc()); - send.user_uuid = Some(user_id); - send.notes = data.notes; - send.max_access_count = match data.max_access_count { + let max_access_count = match data.max_access_count { Some(m) => Some(m.into_i32()?), _ => None, }; - send.expiration_date = data.expiration_date.map(|d| d.naive_utc()); - send.disabled = data.disabled; - send.hide_email = data.hide_email; - send.atype = data.r#type; + + let mut send = Send::new( + data.r#type, + user_id, + data.name, + data.notes, + data_str, + data.key, + max_access_count, + data.emails, + data.expiration_date.map(|d| d.naive_utc()), + data.deletion_date.naive_utc(), + data.disabled, + data.hide_email, + ); send.set_password(data.password.as_deref()); @@ -636,14 +644,14 @@ async fn put_send(send_id: SendId, data: Json, headers: Headers, conn: let data: SendData = data.into_inner(); enforce_disable_hide_email_policy(&data, &headers, &conn).await?; + if !CONFIG.mail_enabled() && data.emails.is_some() { + err!("Email are disabled, cannot send verification code"); + } + let Some(mut send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await else { err!("Send not found", "Send send_id is invalid or does not belong to user") }; - if data.emails.is_some() { - err!("Sends with email verification is not supported"); - } - update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?; Ok(Json(send.to_json())) @@ -694,6 +702,7 @@ pub async fn update_send_from_data( send.expiration_date = data.expiration_date.map(|d| d.naive_utc()); send.hide_email = data.hide_email; send.disabled = data.disabled; + send.emails = data.emails.map(|e| e.to_lowercase()); // Only change the value if it's present if let Some(password) = data.password { diff --git a/src/api/identity.rs b/src/api/identity.rs index 1597698f..2f410426 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -115,6 +115,8 @@ async fn login( let tokens = auth::SendTokens::generate_tokens( data.send_id.as_ref().unwrap(), data.password_hash_b64, + data.email.map(|e| e.to_lowercase()), + data.otp, &client_header.ip, &conn, ) @@ -1161,6 +1163,8 @@ struct ConnectData { // Needed for send access send_id: Option, password_hash_b64: Option, + email: Option, + otp: Option, } fn check_is_some(value: Option<&T>, msg: &str) -> EmptyResult { if value.is_none() { diff --git a/src/auth/send.rs b/src/auth/send.rs index 84500b6a..c107e8e4 100644 --- a/src/auth/send.rs +++ b/src/auth/send.rs @@ -3,14 +3,17 @@ use chrono::{TimeDelta, Utc}; use rocket::request::{FromRequest, Outcome, Request}; use crate::{ + CONFIG, api::ApiResult, auth, auth::{BasicJwtClaims, ClientIp}, + crypto, db::{ DbConn, - models::{Send, SendId}, + models::{SendId, SendOTP}, }, error::{Error, ErrorKind}, + mail, }; fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims { @@ -68,14 +71,18 @@ impl SendTokens { pub async fn generate_tokens( access_id: &str, password: Option, + email: Option, + otp: Option, ip: &ClientIp, conn: &DbConn, ) -> ApiResult { + let now = Utc::now().naive_utc(); + let Some(send_id) = Self::as_send_id(access_id) else { return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false); }; - let Some(mut send) = Send::find_by_uuid(&send_id, conn).await else { + let Some((mut send, o_otp)) = SendOTP::find_with_send(&send_id, email.as_ref(), conn).await else { return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false); }; @@ -86,7 +93,7 @@ impl SendTokens { } if let Some(expiration) = send.expiration_date - && Utc::now().naive_utc() >= expiration + && now >= expiration { return Self::invalid_error(&format!("Send {send_id}, expired"), "send_id_invalid", true); } @@ -99,6 +106,40 @@ impl SendTokens { return Self::invalid_error(&format!("Send {send_id}, disabled"), "send_id_invalid", true); } + if !CONFIG.mail_enabled() && send.emails.is_some() { + return Self::invalid_error( + &format!("Send {send_id}, email disabled but require verification"), + "send_id_invalid", + false, + ); + } + + if let Some(mut split) = send.emails.as_ref().map(|s| s.split(',')) { + match email.as_ref() { + Some(e) if split.any(|s| s == e) => { + match (otp, o_otp) { + (Some(code), Some(db_otp)) if code == db_otp.code && now < db_otp.expiration_date => (), + (None, _) => { + // SEND OTP CODE + let code = crypto::generate_email_token(CONFIG.email_token_size()); + SendOTP::new(send_id, e, code.clone()).save(conn).await?; + mail::send_sends_otp(e, &code).await?; + return Self::expected_error("Email OTP required", "email_and_otp_required"); + } + _ => return Self::invalid_error(&format!("Send {send_id}, disabled"), "send_id_invalid", true), + } + } + Some(e) => { + return Self::invalid_error( + &format!("Send {send_id}, invalid access from {e}"), + "send_id_invalid", + false, + ); + } + None => return Self::expected_error("Email validation required", "email_required"), + } + } + if send.password_hash.is_some() { match password { Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } diff --git a/src/config.rs b/src/config.rs index 49281b6c..9dc8dddc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -567,6 +567,9 @@ make_config! { /// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login. /// Defaults to daily. Set blank to disable this job. purge_incomplete_sso_auth: String, false, def, "0 20 0 * * *".to_owned(); + /// Purge expired sends OTP |> Cron schedule of the job that cleans the sends email verification OTP. + /// Defaults to daily. Set blank to disable this job. + purge_sends_otp: String, false, def, "0 22 0 * * *".to_owned(); }, /// General settings @@ -1724,6 +1727,7 @@ where reg!("email/send_emergency_access_invite", ".html"); reg!("email/send_org_invite", ".html"); reg!("email/send_single_org_removed_from_org", ".html"); + reg!("email/sends_otp", ".html"); reg!("email/smtp_test", ".html"); reg!("email/sso_change_email", ".html"); reg!("email/twofactor_email", ".html"); diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 1cacbcac..f308b3a2 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -35,7 +35,7 @@ pub use self::organization::{ OrganizationId, }; pub use self::send::{ - Send, SendType, + Send, SendOTP, SendType, id::{SendFileId, SendId}, }; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; diff --git a/src/db/models/send.rs b/src/db/models/send.rs index a35bcf8d..8ecb7845 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -1,4 +1,4 @@ -use chrono::{NaiveDateTime, Utc}; +use chrono::{NaiveDateTime, TimeDelta, Utc}; use data_encoding::BASE64URL_NOPAD; use diesel::prelude::*; use serde_json::Value; @@ -8,7 +8,10 @@ use crate::{ CONFIG, api::EmptyResult, config::PathType, - db::{DbConn, schema::sends}, + db::{ + DbConn, DbPool, + schema::{sends, sends_otp}, + }, error::MapResult, util::{LowerCase, NumberOrString, format_date}, }; @@ -16,7 +19,7 @@ use crate::{ use super::{OrganizationId, User, UserId}; use id::SendId; -#[derive(Identifiable, Queryable, Insertable, AsChangeset)] +#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] #[diesel(table_name = sends)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] @@ -38,6 +41,7 @@ pub struct Send { pub max_access_count: Option, pub access_count: i32, + pub emails: Option, pub creation_date: NaiveDateTime, pub revision_date: NaiveDateTime, @@ -57,7 +61,7 @@ pub enum SendType { enum SendAuthType { #[allow(dead_code)] // Send requires email OTP verification - Email = 0, // Not yet supported by Vaultwarden + Email = 0, // Send requires a password Password = 1, // Send requires no auth @@ -65,17 +69,29 @@ enum SendAuthType { } impl Send { - pub fn new(atype: i32, name: String, data: String, akey: String, deletion_date: NaiveDateTime) -> Self { + #[allow(clippy::too_many_arguments)] + pub fn new( + atype: i32, + user_uuid: UserId, + name: String, + notes: Option, + data: String, + akey: String, + max_access_count: Option, + emails: Option, + expiration_date: Option, + deletion_date: NaiveDateTime, + disabled: bool, + hide_email: Option, + ) -> Self { let now = Utc::now().naive_utc(); Self { uuid: SendId::from(crate::util::get_uuid()), - user_uuid: None, + user_uuid: Some(user_uuid), organization_uuid: None, - name, - notes: None, - + notes, atype, data, akey, @@ -83,16 +99,17 @@ impl Send { password_salt: None, password_iter: None, - max_access_count: None, + max_access_count, access_count: 0, + emails: emails.map(|e| e.to_lowercase()), creation_date: now, revision_date: now, - expiration_date: None, + expiration_date, deletion_date, - disabled: false, - hide_email: None, + disabled, + hide_email, } } @@ -159,9 +176,10 @@ impl Send { "maxAccessCount": self.max_access_count, "accessCount": self.access_count, "password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)), - "authType": if self.password_hash.is_some() { SendAuthType::Password as i32 } else { SendAuthType::None as i32 }, + "authType": if self.password_hash.is_some() { SendAuthType::Password } else if self.emails.is_some() { SendAuthType::Email } else { SendAuthType::None } as i32, "disabled": self.disabled, "hideEmail": self.hide_email.unwrap_or(false), + "emails": self.emails, "revisionDate": format_date(&self.revision_date), "expirationDate": self.expiration_date.as_ref().map(format_date), @@ -199,24 +217,16 @@ impl Send { self.revision_date = Utc::now().naive_utc(); db_run! { conn: - sqlite, mysql { - match diesel::replace_into(sends::table) + mysql { + diesel::insert_into(sends::table) .values(&*self) + .on_conflict(diesel::dsl::DuplicatedKeys) + .do_update() + .set(&*self) .execute(conn) - { - Ok(_) => Ok(()), - // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. - Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { - diesel::update(sends::table) - .filter(sends::uuid.eq(&self.uuid)) - .set(&*self) - .execute(conn) - .map_res("Error saving send") - } - Err(e) => Err(e.into()), - }.map_res("Error saving send") + .map_res("Error saving send") } - postgresql { + postgresql, sqlite { diesel::insert_into(sends::table) .values(&*self) .on_conflict(sends::uuid) @@ -379,3 +389,94 @@ pub mod id { } } } + +#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] +#[diesel(table_name = sends_otp)] +#[diesel(treat_none_as_null = true)] +#[diesel(primary_key(send_uuid, email))] +pub struct SendOTP { + pub send_uuid: SendId, + pub email: String, + + pub code: String, + + pub creation_date: NaiveDateTime, + pub revision_date: NaiveDateTime, + pub expiration_date: NaiveDateTime, +} + +impl SendOTP { + pub fn new(send_id: SendId, email: &str, code: String) -> Self { + let now = Utc::now().naive_utc(); + + Self { + send_uuid: send_id, + email: email.to_lowercase(), + code, + creation_date: now, + revision_date: now, + expiration_date: now + TimeDelta::try_minutes(5).unwrap(), + } + } + + pub async fn save(&self, conn: &DbConn) -> EmptyResult { + db_run! { conn: + mysql { + diesel::insert_into(sends_otp::table) + .values(&*self) + .on_conflict(diesel::dsl::DuplicatedKeys) + .do_update() + .set(( + sends_otp::code.eq(&self.code), + sends_otp::expiration_date.eq(self.expiration_date), + sends_otp::revision_date.eq(Utc::now().naive_utc()), + )) + .execute(conn) + .map_res("Error saving send_otp") + } + postgresql, sqlite { + diesel::insert_into(sends_otp::table) + .values(&*self) + .on_conflict((sends_otp::send_uuid, sends_otp::email)) + .do_update() + .set(( + sends_otp::code.eq(&self.code), + sends_otp::expiration_date.eq(self.expiration_date), + sends_otp::revision_date.eq(Utc::now().naive_utc()), + )) + .execute(conn) + .map_res("Error saving send_otp") + } + } + } + + pub async fn find_with_send(uuid: &SendId, email: Option<&String>, conn: &DbConn) -> Option<(Send, Option)> { + if let Some(mail) = email.map(|e| e.to_lowercase()) { + conn.run(move |conn| { + sends::table + .left_join(sends_otp::table.on(sends::uuid.eq(sends_otp::send_uuid).and(sends_otp::email.eq(mail)))) + .select(<(Send, Option)>::as_select()) + .filter(sends::uuid.eq(uuid)) + .first::<(Send, Option)>(conn) + .ok() + }) + .await + } else { + Send::find_by_uuid(uuid, conn).await.map(|s| (s, None)) + } + } + + pub async fn delete_expired(pool: DbPool) -> EmptyResult { + debug!("Purging expired sends_otp"); + if let Ok(conn) = pool.get().await { + conn.run(move |conn| { + diesel::delete(sends_otp::table.filter(sends_otp::expiration_date.lt(Utc::now().naive_utc()))) + .execute(conn) + .map_res("Error deleting expired Sends OTP") + }) + .await + } else { + err!("Failed to get DB connection while purging expired sends_otp") + } + } +} diff --git a/src/db/schema.rs b/src/db/schema.rs index af342186..7d9bb4d9 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -144,6 +144,7 @@ table! { password_iter -> Nullable, max_access_count -> Nullable, access_count -> Integer, + emails -> Nullable, creation_date -> Timestamp, revision_date -> Timestamp, expiration_date -> Nullable, @@ -153,6 +154,17 @@ table! { } } +table! { + sends_otp (send_uuid, email) { + send_uuid -> Text, + email -> Text, + code -> Text, + creation_date -> Timestamp, + revision_date -> Timestamp, + expiration_date -> Timestamp, + } +} + table! { twofactor (uuid) { uuid -> Text, @@ -366,6 +378,7 @@ joinable!(folders_ciphers -> folders (folder_uuid)); joinable!(org_policies -> organizations (org_uuid)); joinable!(sends -> organizations (organization_uuid)); joinable!(sends -> users (user_uuid)); +joinable!(sends_otp -> sends (send_uuid)); joinable!(twofactor -> users (user_uuid)); joinable!(users_collections -> collections (collection_uuid)); joinable!(users_collections -> users (user_uuid)); @@ -396,6 +409,7 @@ allow_tables_to_appear_in_same_query!( org_policies, organizations, sends, + sends_otp, sso_users, twofactor, users, diff --git a/src/mail.rs b/src/mail.rs index f31234d7..3c96e8ce 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -650,6 +650,19 @@ pub async fn send_protected_action_token(address: &str, token: &str) -> EmptyRes send_email(address, &subject, body_html, body_text).await } +pub async fn send_sends_otp(address: &str, code: &str) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/sends_otp", + json!({ + "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), + "token": code, + }), + )?; + + send_email(address, &subject, body_html, body_text).await +} + async fn send_with_selected_transport(email: Message) -> EmptyResult { if CONFIG.use_sendmail() { match sendmail_transport().send(email).await { diff --git a/src/main.rs b/src/main.rs index 15467ea4..21037efe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -745,6 +745,13 @@ fn schedule_jobs(pool: db::DbPool) { })); } + // Purge expired sends OTP (default to daily at 00h22). + if !CONFIG.purge_sends_otp().is_empty() { + sched.add(Job::new(CONFIG.purge_sends_otp().parse().unwrap(), || { + runtime.spawn(db::models::SendOTP::delete_expired(pool.clone())); + })); + } + // Periodically check for jobs to run. We probably won't need any // jobs that run more often than once a minute, so a default poll // interval of 30 seconds should be sufficient. Users who want to diff --git a/src/static/templates/email/sends_otp.hbs b/src/static/templates/email/sends_otp.hbs new file mode 100644 index 00000000..8aae1a5d --- /dev/null +++ b/src/static/templates/email/sends_otp.hbs @@ -0,0 +1,6 @@ +Your Vaultwarden Sends Verification Code + +Your email verification code is: {{token}} + +Use this code to access the shared secret in Vaultwarden. +{{> email/email_footer_text }} diff --git a/src/static/templates/email/sends_otp.html.hbs b/src/static/templates/email/sends_otp.html.hbs new file mode 100644 index 00000000..b94e0dd9 --- /dev/null +++ b/src/static/templates/email/sends_otp.html.hbs @@ -0,0 +1,16 @@ +Your Vaultwarden Sends Verification Code + +{{> email/email_header }} + + + + + + + +
+ Your email verification code is: {{token}} +
+ Use this code to access the shared secret in Vaultwarden. +
+{{> email/email_footer }} From 43ad2952102cc53f6dd0ea1673623dcbedb2283b Mon Sep 17 00:00:00 2001 From: Timshel Date: Mon, 22 Jun 2026 16:23:42 +0200 Subject: [PATCH 2/2] Refactor Sends table --- .../2026-06-18-120000_add_sends_emails/up.sql | 38 +++++++++++++++ .../2026-06-18-120000_add_sends_emails/up.sql | 7 +++ .../down.sql | 2 - .../2026-06-18-120000_add_sends_emails/up.sql | 41 +++++++++++++++- src/api/core/sends.rs | 6 +-- src/api/notifications.rs | 3 +- src/api/push.rs | 4 +- src/db/models/send.rs | 47 +++++-------------- src/db/schema.rs | 6 +-- 9 files changed, 105 insertions(+), 49 deletions(-) diff --git a/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql b/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql index ae69e9d2..299c9093 100644 --- a/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql +++ b/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql @@ -11,3 +11,41 @@ CREATE TABLE sends_otp ( PRIMARY KEY(send_uuid, email) ); + + +DELETE FROM sends where user_uuid IS NULL; +UPDATE sends SET hide_email = false WHERE hide_email IS NULL; + +SELECT if ( + EXISTS( + SELECT CONSTRAINT_NAME FROM information_schema.table_constraints + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sends' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' + AND CONSTRAINT_NAME = 'sends_ibfk_2' + ) + ,'ALTER TABLE sends DROP FOREIGN KEY `sends_ibfk_2`' + ,'SELECT "info: FK sends_ibfk_2 does not exist."' +) INTO @drop_stmt; +PREPARE drop_stmt FROM @drop_stmt; +EXECUTE drop_stmt; + +SELECT if ( + EXISTS( + SELECT CONSTRAINT_NAME FROM information_schema.table_constraints + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sends' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' + AND CONSTRAINT_NAME = '2' + ) + ,'ALTER TABLE sends DROP FOREIGN KEY `2`' + ,'SELECT "info: FK sends 2 does not exist."' +) INTO @drop_stmt; +PREPARE drop_stmt FROM @drop_stmt; +EXECUTE drop_stmt; + +DEALLOCATE PREPARE drop_stmt; + +ALTER TABLE sends DROP COLUMN organization_uuid; +ALTER TABLE sends MODIFY user_uuid CHAR(36) NOT NULL; +ALTER TABLE sends MODIFY hide_email BOOLEAN NOT NULL; diff --git a/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql b/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql index bdbc4b95..9f7a0921 100644 --- a/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql +++ b/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql @@ -11,3 +11,10 @@ CREATE TABLE sends_otp ( PRIMARY KEY(send_uuid, email) ); + + +DELETE FROM sends where user_uuid IS NULL; +UPDATE sends SET hide_email = false WHERE hide_email IS NULL; +ALTER TABLE sends DROP COLUMN organization_uuid; +ALTER TABLE sends ALTER COLUMN user_uuid SET NOT NULL; +ALTER TABLE sends ALTER COLUMN hide_email SET NOT NULL; diff --git a/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql b/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql index 2cfff832..e69de29b 100644 --- a/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql +++ b/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql @@ -1,2 +0,0 @@ -ALTER TABLE sends DROP COLUMN emails; -DROP TABLE sends_otp; diff --git a/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql b/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql index 86ab7a45..28b6daab 100644 --- a/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql +++ b/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql @@ -1,4 +1,43 @@ -ALTER TABLE sends ADD COLUMN emails TEXT; +ALTER TABLE sends RENAME TO sends_old; + +CREATE TABLE sends ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL REFERENCES users (uuid), + + name TEXT NOT NULL, + notes TEXT, + + atype INTEGER NOT NULL, + data TEXT NOT NULL, + akey TEXT NOT NULL, + password_hash BLOB, + password_salt BLOB, + password_iter INTEGER, + emails TEXT, + + max_access_count INTEGER, + access_count INTEGER NOT NULL, + + creation_date DATETIME NOT NULL, + revision_date DATETIME NOT NULL, + expiration_date DATETIME, + deletion_date DATETIME NOT NULL, + + disabled BOOLEAN NOT NULL, + hide_email BOOLEAN NOT NULL +); + +INSERT INTO sends( + uuid, user_uuid, name, notes, atype, data, akey, password_hash, password_salt, password_iter, + max_access_count, access_count, creation_date, revision_date, expiration_date, deletion_date, + disabled, hide_email +) SELECT uuid, user_uuid, name, notes, atype, data, akey, password_hash, password_salt, password_iter, + max_access_count, access_count, creation_date, revision_date, expiration_date, deletion_date, + disabled, + CASE WHEN hide_email IS NOT NULL THEN hide_email ELSE false END + FROM sends_old WHERE user_uuid IS NOT NULL; + +DROP TABLE sends_old; CREATE TABLE sends_otp ( send_uuid TEXT NOT NULL REFERENCES sends(uuid) ON DELETE CASCADE ON UPDATE CASCADE, diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index cafc29df..6ffbeb96 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -172,7 +172,7 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { data.expiration_date.map(|d| d.naive_utc()), data.deletion_date.naive_utc(), data.disabled, - data.hide_email, + data.hide_email.unwrap_or(false), ); send.set_password(data.password.as_deref()); @@ -665,7 +665,7 @@ pub async fn update_send_from_data( nt: &Notify<'_>, ut: UpdateType, ) -> EmptyResult { - if send.user_uuid.as_ref() != Some(&headers.user.uuid) { + if send.user_uuid != headers.user.uuid { err!("Send is not owned by user") } @@ -700,7 +700,7 @@ pub async fn update_send_from_data( _ => None, }; send.expiration_date = data.expiration_date.map(|d| d.naive_utc()); - send.hide_email = data.hide_email; + send.hide_email = data.hide_email.unwrap_or(false); send.disabled = data.disabled; send.emails = data.emails.map(|e| e.to_lowercase()); diff --git a/src/api/notifications.rs b/src/api/notifications.rs index 80067433..3c582197 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -465,12 +465,11 @@ impl WebSocketUsers { if *NOTIFICATIONS_DISABLED { return; } - let user_id = convert_option(send.user_uuid.as_deref()); let data = create_update( vec![ ("Id".into(), send.uuid.to_string().into()), - ("UserId".into(), user_id), + ("UserId".into(), send.user_uuid.to_string().into()), ("RevisionDate".into(), serialize_date(send.revision_date)), ], ut, diff --git a/src/api/push.rs b/src/api/push.rs index e87a0985..ac27d85e 100644 --- a/src/api/push.rs +++ b/src/api/push.rs @@ -244,9 +244,7 @@ pub async fn push_folder_update(ut: UpdateType, folder: &Folder, device: &Device } pub async fn push_send_update(ut: UpdateType, send: &Send, device: &Device, conn: &DbConn) { - if let Some(s) = &send.user_uuid - && Device::check_user_has_push_device(s, conn).await - { + if Device::check_user_has_push_device(&send.user_uuid, conn).await { tokio::task::spawn(send_to_push_relay(json!({ "userId": send.user_uuid, "organizationId": null, diff --git a/src/db/models/send.rs b/src/db/models/send.rs index 8ecb7845..05b28bb4 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -16,7 +16,7 @@ use crate::{ util::{LowerCase, NumberOrString, format_date}, }; -use super::{OrganizationId, User, UserId}; +use super::{User, UserId}; use id::SendId; #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] @@ -26,8 +26,7 @@ use id::SendId; pub struct Send { pub uuid: SendId, - pub user_uuid: Option, - pub organization_uuid: Option, + pub user_uuid: UserId, pub name: String, pub notes: Option, @@ -49,7 +48,7 @@ pub struct Send { pub deletion_date: NaiveDateTime, pub disabled: bool, - pub hide_email: Option, + pub hide_email: bool, } #[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] @@ -82,14 +81,13 @@ impl Send { expiration_date: Option, deletion_date: NaiveDateTime, disabled: bool, - hide_email: Option, + hide_email: bool, ) -> Self { let now = Utc::now().naive_utc(); Self { uuid: SendId::from(crate::util::get_uuid()), - user_uuid: Some(user_uuid), - organization_uuid: None, + user_uuid, name, notes, atype, @@ -139,19 +137,13 @@ impl Send { } pub async fn creator_identifier(&self, conn: &DbConn) -> Option { - if let Some(hide_email) = self.hide_email - && hide_email + if !self.hide_email + && let Some(user) = User::find_by_uuid(&self.user_uuid, conn).await { - return None; + Some(user.email) + } else { + None } - - if let Some(user_uuid) = &self.user_uuid - && let Some(user) = User::find_by_uuid(user_uuid, conn).await - { - return Some(user.email); - } - - None } pub fn to_json(&self) -> Value { @@ -178,7 +170,7 @@ impl Send { "password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)), "authType": if self.password_hash.is_some() { SendAuthType::Password } else if self.emails.is_some() { SendAuthType::Email } else { SendAuthType::None } as i32, "disabled": self.disabled, - "hideEmail": self.hide_email.unwrap_or(false), + "hideEmail": self.hide_email, "emails": self.emails, "revisionDate": format_date(&self.revision_date), @@ -260,14 +252,8 @@ impl Send { } pub async fn update_users_revision(&self, conn: &DbConn) -> Vec { - let mut user_uuids = Vec::new(); - if let Some(user_uuid) = &self.user_uuid { - User::update_uuid_revision(user_uuid, conn).await; - user_uuids.push(user_uuid.clone()); - } else { - // Belongs to Organization, not implemented - } - user_uuids + User::update_uuid_revision(&self.user_uuid, conn).await; + vec![self.user_uuid.clone()] } pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { @@ -329,13 +315,6 @@ impl Send { Some(total) } - pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { - conn.run(move |conn| { - sends::table.filter(sends::organization_uuid.eq(org_uuid)).load::(conn).expect("Error loading sends") - }) - .await - } - pub async fn find_by_past_deletion_date(conn: &DbConn) -> Vec { let now = Utc::now().naive_utc(); conn.run(move |conn| { diff --git a/src/db/schema.rs b/src/db/schema.rs index 7d9bb4d9..2f0ea0dc 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -132,8 +132,7 @@ table! { table! { sends (uuid) { uuid -> Text, - user_uuid -> Nullable, - organization_uuid -> Nullable, + user_uuid -> Text, name -> Text, notes -> Nullable, atype -> Integer, @@ -150,7 +149,7 @@ table! { expiration_date -> Nullable, deletion_date -> Timestamp, disabled -> Bool, - hide_email -> Nullable, + hide_email -> Bool, } } @@ -376,7 +375,6 @@ joinable!(folders -> users (user_uuid)); joinable!(folders_ciphers -> ciphers (cipher_uuid)); joinable!(folders_ciphers -> folders (folder_uuid)); joinable!(org_policies -> organizations (org_uuid)); -joinable!(sends -> organizations (organization_uuid)); joinable!(sends -> users (user_uuid)); joinable!(sends_otp -> sends (send_uuid)); joinable!(twofactor -> users (user_uuid));