diff --git a/.env.template b/.env.template index 272c7738..545bb0d6 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/Cargo.lock b/Cargo.lock index 6c9d9237..3f9b8ee0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4961,6 +4961,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -5991,6 +6002,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_repr", "subtle", "svg-hush", "syslog", diff --git a/Cargo.toml b/Cargo.toml index 764fbd53..963e0117 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,6 +105,7 @@ tokio-util = { version = "0.7.19", features = ["compat"] } # A generic serialization/deserialization framework serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" +serde_repr = "0.1.21" # A safe, extensible ORM and Query builder diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] } diff --git a/migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql b/migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql deleted file mode 100644 index e69de29b..00000000 diff --git a/migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql b/migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql deleted file mode 100644 index d25396cf..00000000 --- a/migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD COLUMN key_id TEXT; 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..940f3d3e --- /dev/null +++ b/migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql @@ -0,0 +1,10 @@ +DROP TABLE sends_otp; + +ALTER TABLE sends DROP COLUMN auth_type; +ALTER TABLE sends DROP COLUMN emails; + +ALTER TABLE sends MODIFY user_uuid CHAR(36) NULL; +ALTER TABLE sends MODIFY hide_email BOOLEAN NULL; + +ALTER TABLE sends ADD COLUMN organization_uuid CHAR(36); +ALTER TABLE sends ADD FOREIGN KEY (organization_uuid) REFERENCES organizations(uuid); 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..324a7b4f --- /dev/null +++ b/migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql @@ -0,0 +1,34 @@ +ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2; +UPDATE sends SET auth_type = 1 WHERE password_hash is NOT NULL; + +ALTER TABLE sends ADD COLUMN emails TEXT; + +CREATE TABLE sends_otp ( + send_uuid CHAR(36) NOT NULL, + 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) +); + +ALTER TABLE sends_otp ADD CONSTRAINT fk_send_uuid FOREIGN KEY (send_uuid) REFERENCES sends(uuid) ON DELETE CASCADE ON UPDATE CASCADE; + +DELETE FROM sends where user_uuid IS NULL; +UPDATE sends SET hide_email = false WHERE hide_email IS NULL; + +SELECT CONCAT('ALTER TABLE sends DROP FOREIGN KEY `', constraint_name, '`') INTO @drop_stmt + FROM information_schema.key_column_usage + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sends' + AND COLUMN_NAME = 'organization_uuid'; +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/down.sql b/migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql new file mode 100644 index 00000000..f6229112 --- /dev/null +++ b/migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql @@ -0,0 +1,9 @@ +DROP TABLE sends_otp; + +ALTER TABLE sends DROP COLUMN auth_type; +ALTER TABLE sends DROP COLUMN emails; + +ALTER TABLE sends ALTER COLUMN user_uuid DROP NOT NULL; +ALTER TABLE sends ALTER COLUMN hide_email DROP NOT NULL; + +ALTER TABLE sends ADD COLUMN organization_uuid TEXT REFERENCES organizations(uuid); 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..94022002 --- /dev/null +++ b/migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql @@ -0,0 +1,23 @@ +ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2; +UPDATE sends SET auth_type = 1 WHERE password_hash is NOT NULL; + +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) +); + + +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 new file mode 100644 index 00000000..eaba43d7 --- /dev/null +++ b/migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql @@ -0,0 +1,41 @@ +DROP TABLE sends_otp; + +ALTER TABLE sends RENAME TO sends_old; + +CREATE TABLE sends ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT REFERENCES users (uuid), + organization_uuid TEXT REFERENCES organizations (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, + + 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 +); + +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, hide_email + FROM sends_old WHERE user_uuid IS NOT NULL; + +DROP TABLE sends_old; 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..582c8378 --- /dev/null +++ b/migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql @@ -0,0 +1,54 @@ +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, + auth_type 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, auth_type +) 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, + CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 2 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, + 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 ddb8009d..1a09ec9d 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,20 +58,26 @@ 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(); - await page.getByRole('button', { name: 'New' }).click(); + 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('Password'); @@ -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 0b1711a1..94a4ad4f 100644 --- a/playwright/tests/setups/2fa.ts +++ b/playwright/tests/setups/2fa.ts @@ -83,7 +83,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 2a604b74..b5f9047b 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -16,7 +16,7 @@ use crate::{ config::PathType, db::{ DbConn, DbPool, - models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId}, + models::{Device, OrgPolicy, OrgPolicyType, Send, SendAuthType, SendFileId, SendId, SendType, UserId}, }, util::{NumberOrString, save_temp_file}, }; @@ -69,7 +69,8 @@ pub async fn purge_sends(pool: DbPool) { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SendData { - r#type: i32, + r#type: SendType, + auth_type: SendAuthType, pub key: String, password: Option, max_access_count: Option, @@ -127,12 +128,9 @@ async fn enforce_disable_hide_email_policy(data: &SendData, headers: &Headers, c } fn create_send(data: SendData, user_id: UserId) -> ApiResult { - let data_val = if data.r#type == SendType::Text as i32 { - data.text - } else if data.r#type == SendType::File as i32 { - data.file - } else { - err!("Invalid Send type") + let data_val = match data.r#type { + SendType::Text => data.text, + SendType::File => data.file, }; let data_str = if let Some(mut d) = data_val { @@ -148,21 +146,30 @@ 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, + data.auth_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.unwrap_or(false), + ); send.set_password(data.password.as_deref()); @@ -197,7 +204,7 @@ async fn post_send(data: Json, headers: Headers, conn: DbConn, nt: Not let data: SendData = data.into_inner(); enforce_disable_hide_email_policy(&data, &headers, &conn).await?; - if data.r#type == SendType::File as i32 { + if data.r#type == SendType::File { err!("File sends should use /api/sends/file/v2") } @@ -227,7 +234,7 @@ async fn post_send_file_v2(data: Json, headers: Headers, conn: DbConn) let data = data.into_inner(); - if data.r#type != SendType::File as i32 { + if data.r#type != SendType::File { err!("Send content is not a file"); } @@ -469,14 +476,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())) @@ -490,11 +497,11 @@ 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") } - if send.atype != data.r#type { + if send.atype != data.r#type as i32 { err!("Sends can't change type") } @@ -506,7 +513,7 @@ async fn update_send_from_data( // When updating a file Send, we receive nulls in the File field, as it's immutable, // so we only need to update the data field in the Text case - if data.r#type == SendType::Text as i32 { + if data.r#type == SendType::Text { let data_str = if let Some(mut d) = data.text { d.as_object_mut().and_then(|d| d.remove("response")); serde_json::to_string(&d)? @@ -525,12 +532,26 @@ 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; - // Only change the value if it's present - if let Some(password) = data.password { - send.set_password(Some(&password)); + send.auth_type = data.auth_type as i32; + match data.auth_type { + SendAuthType::Email => { + send.emails = data.emails.map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()); + send.set_password(None); + } + SendAuthType::Password => { + send.emails = None; + // Only change the value if it's present + if let Some(password) = data.password { + send.set_password(Some(&password)); + } + } + SendAuthType::None => { + send.emails = None; + send.set_password(None); + } } send.save(conn).await?; diff --git a/src/api/identity.rs b/src/api/identity.rs index b61258cf..bdcda769 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -105,6 +105,8 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo 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, ) @@ -1165,6 +1167,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/api/notifications.rs b/src/api/notifications.rs index 32204916..16429b44 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -516,12 +516,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/auth/send.rs b/src/auth/send.rs index 518b7098..18e2e850 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,6 +71,8 @@ impl SendTokens { pub async fn generate_tokens( access_id: &str, password: Option, + email: Option, + otp: Option, ip: &ClientIp, conn: &DbConn, ) -> ApiResult { @@ -75,7 +80,7 @@ impl SendTokens { return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false); }; - let Some(send) = Send::find_by_uuid(&send_id, conn).await else { + let Some((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); }; @@ -89,6 +94,51 @@ impl SendTokens { return Self::invalid_error(&format!("Send {send_id}, not accessible"), "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(',')) { + let now = Utc::now().naive_utc(); + match email.map(|e| e.trim().to_lowercase()) { + Some(e) if split.any(|s| s.trim() == e) => { + if let Some(code) = otp { + if let Some(db_otp) = o_otp { + if crypto::ct_eq(&code, &db_otp.code) && now < db_otp.expiration_date { + db_otp.delete(conn).await?; + } else { + return Self::expected_error("Email OTP required", "email_and_otp_required"); + } + } else { + return Self::invalid_error(&format!("Send {send_id}, disabled"), "send_id_invalid", true); + } + } else { + if let Some(db_otp) = o_otp + && now + TimeDelta::try_minutes(1).unwrap() < db_otp.expiration_date + { + // Code is still valid for a minute do nothing + } else { + // SEND OTP CODE + let code = crypto::generate_email_token(CONFIG.email_token_size()); + let send_otp = SendOTP::new(send_id, &e, code.clone()); + send_otp.save(conn).await?; + if let Err(e) = mail::send_sends_otp(&e, &code).await { + drop(send_otp.delete(conn).await); + Err(e)?; + } + } + return Self::expected_error("Email OTP required", "email_and_otp_required"); + } + } + Some(_) => return Self::expected_error("Email OTP required", "email_and_otp_required"), + 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 a56748f4..fa6a1051 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 @@ -1744,6 +1747,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 0e4073a5..b726a326 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -34,7 +34,7 @@ pub use self::organization::{ Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, OrganizationId, }; -pub use self::send::{Send, SendFileId, SendId, SendType}; +pub use self::send::{Send, SendAuthType, SendFileId, SendId, SendOTP, SendType}; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_duo_context::TwoFactorDuoContext; diff --git a/src/db/models/send.rs b/src/db/models/send.rs index 721378f3..d055bf12 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -1,38 +1,42 @@ use std::path::Path; -use chrono::{NaiveDateTime, Utc}; +use chrono::{NaiveDateTime, TimeDelta, Utc}; use data_encoding::BASE64URL_NOPAD; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; use macros::{IdFromParam, UuidFromParam}; use serde_json::Value; +use serde_repr::Deserialize_repr; use uuid::Uuid; 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}, }; -use super::{OrganizationId, User, UserId}; +use super::{User, UserId}; -#[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))] pub struct Send { pub uuid: SendId, - pub user_uuid: Option, - pub organization_uuid: Option, + pub user_uuid: UserId, pub name: String, pub notes: Option, pub atype: i32, + pub auth_type: i32, pub data: String, pub akey: String, pub password_hash: Option>, @@ -41,6 +45,7 @@ pub struct Send { pub max_access_count: Option, pub access_count: i32, + pub emails: Option, pub creation_date: NaiveDateTime, pub revision_date: NaiveDateTime, @@ -48,19 +53,21 @@ 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)] +#[derive(Copy, Clone, Deserialize_repr, PartialEq, Eq, num_derive::FromPrimitive)] +#[repr(u8)] pub enum SendType { Text = 0, File = 1, } -enum SendAuthType { - #[allow(dead_code)] +#[derive(Copy, Clone, Deserialize_repr, Eq, PartialEq, num_derive::FromPrimitive)] +#[repr(u8)] +pub enum SendAuthType { // Send requires email OTP verification - Email = 0, // Not yet supported by Vaultwarden + Email = 0, // Send requires a password Password = 1, // Send requires no auth @@ -68,34 +75,48 @@ 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: SendType, + auth_type: SendAuthType, + 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: bool, + ) -> Self { let now = Utc::now().naive_utc(); Self { uuid: SendId::from(crate::util::get_uuid()), - user_uuid: None, - organization_uuid: None, - + user_uuid, name, - notes: None, - - atype, + notes, + atype: atype as i32, + auth_type: auth_type as i32, data, akey, password_hash: None, password_salt: None, password_iter: None, - max_access_count: None, + max_access_count, access_count: 0, + emails: emails.map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()), creation_date: now, revision_date: now, - expiration_date: None, + expiration_date, deletion_date, - disabled: false, - hide_email: None, + disabled, + hide_email, } } @@ -125,19 +146,13 @@ impl Send { } pub async fn creator_identifier(&self, conn: &DbConn) -> Option { - if let Some(hide_email) = self.hide_email - && hide_email - { - return None; - } - - if let Some(user_uuid) = &self.user_uuid - && let Some(user) = User::find_by_uuid(user_uuid, conn).await + if !self.hide_email + && let Some(user) = User::find_by_uuid(&self.user_uuid, conn).await { - return Some(user.email); + Some(user.email) + } else { + None } - - None } fn access_id(&self) -> String { @@ -166,9 +181,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": self.auth_type, "disabled": self.disabled, - "hideEmail": self.hide_email.unwrap_or(false), + "hideEmail": self.hide_email, + "emails": self.emails, "revisionDate": format_date(&self.revision_date), "expirationDate": self.expiration_date.as_ref().map(format_date), @@ -296,14 +312,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 { @@ -397,3 +407,103 @@ impl AsRef for SendFileId { Path::new(&self.0) } } + +#[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 delete(&self, conn: &DbConn) -> EmptyResult { + conn.run(move |conn| { + diesel::delete(sends_otp::table.filter(sends_otp::send_uuid.eq(&self.send_uuid))) + .execute(conn) + .map_res("Error deleting send_otp") + }) + .await + } + + 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 ae1e3216..3bdc9d83 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -133,11 +133,11 @@ table! { table! { sends (uuid) { uuid -> Text, - user_uuid -> Nullable, - organization_uuid -> Nullable, + user_uuid -> Text, name -> Text, notes -> Nullable, atype -> Integer, + auth_type -> Integer, data -> Text, akey -> Text, password_hash -> Nullable, @@ -145,12 +145,24 @@ table! { password_iter -> Nullable, max_access_count -> Nullable, access_count -> Integer, + emails -> Nullable, creation_date -> Timestamp, revision_date -> Timestamp, expiration_date -> Nullable, deletion_date -> Timestamp, disabled -> Bool, - hide_email -> Nullable, + hide_email -> Bool, + } +} + +table! { + sends_otp (send_uuid, email) { + send_uuid -> Text, + email -> Text, + code -> Text, + creation_date -> Timestamp, + revision_date -> Timestamp, + expiration_date -> Timestamp, } } @@ -366,8 +378,8 @@ 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)); joinable!(users_collections -> collections (collection_uuid)); joinable!(users_collections -> users (user_uuid)); @@ -398,6 +410,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 2efb8d70..93e4e27d 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -684,6 +684,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 f27729d6..68910779 100644 --- a/src/main.rs +++ b/src/main.rs @@ -743,6 +743,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 }}