Browse Source

Sends email verification

pull/7363/head
Timshel 1 month ago
parent
commit
9f7fcc0e69
  1. 4
      .env.template
  2. 2
      migrations/mysql/2026-06-18-120000_add_sends_emails/down.sql
  3. 13
      migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql
  4. 2
      migrations/postgresql/2026-06-18-120000_add_sends_emails/down.sql
  5. 13
      migrations/postgresql/2026-06-18-120000_add_sends_emails/up.sql
  6. 2
      migrations/sqlite/2026-06-18-120000_add_sends_emails/down.sql
  7. 13
      migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql
  8. 89
      playwright/tests/send.spec.ts
  9. 2
      playwright/tests/setups/2fa.ts
  10. 37
      src/api/core/sends.rs
  11. 4
      src/api/identity.rs
  12. 47
      src/auth/send.rs
  13. 4
      src/config.rs
  14. 2
      src/db/models/mod.rs
  15. 159
      src/db/models/send.rs
  16. 14
      src/db/schema.rs
  17. 13
      src/mail.rs
  18. 7
      src/main.rs
  19. 6
      src/static/templates/email/sends_otp.hbs
  20. 16
      src/static/templates/email/sends_otp.html.hbs

4
.env.template

@ -187,6 +187,10 @@
## Cron schedule of the job that cleans sso auth from incomplete flow ## 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. ## Defaults to daily (20 minutes after midnight). Set blank to disable this job.
# PURGE_INCOMPLETE_SSO_AUTH="0 20 0 * * *" # 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 ### ### General settings ###

2
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;

13
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)
);

2
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;

13
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)
);

2
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;

13
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)
);

89
playwright/tests/send.spec.ts

@ -1,21 +1,46 @@
import { test, expect, type Page, type TestInfo } from '@playwright/test'; import { test, expect, type Page, type TestInfo } from '@playwright/test';
import * as OTPAuth from "otpauth"; import * as OTPAuth from "otpauth";
import { MailDev } from 'maildev';
import * as utils from "../global-utils"; 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 users = utils.loadEnv();
let mailserver;
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { 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 ({}) => { test.afterAll('Teardown', async ({}) => {
utils.stopVault(); utils.stopVault();
if( mailserver ){
await mailserver.close();
}
}); });
test('Send', async ({ browser, page }) => { 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 () => { const send_url = await test.step('Create', async () => {
await page.getByRole('link', { name: 'Send' }).click(); await page.getByRole('link', { name: 'Send' }).click();
@ -33,15 +58,21 @@ test('Send', async ({ browser, page }) => {
return await page.evaluate(() => navigator.clipboard.readText()); return await page.evaluate(() => navigator.clipboard.readText());
}); });
const context2 = await browser.newContext();
const page2 = await context2.newPage();
await test.step('View', async () => { await test.step('View', async () => {
await page2.goto(send_url, { waitUntil: 'domcontentloaded' }); await page2.goto(send_url, { waitUntil: 'domcontentloaded' });
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible(); await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Test' })).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 () => { const pwd_url = await test.step('Create with password', async () => {
await page.getByRole('link', { name: 'Send' }).click(); await page.getByRole('link', { name: 'Send' }).click();
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); 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(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).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();
}); });

2
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 { export async function retrieveEmailCode(test: Test, page: Page, mailBuffer: MailBuffer): string {
return await test.step('retrieve code', async () => { 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(); const page2 = await page.context().newPage();
await page2.setContent(codeMail.html); await page2.setContent(codeMail.html);
const code = await page2.getByTestId("2fa").innerText(); const code = await page2.getByTestId("2fa").innerText();

37
src/api/core/sends.rs

@ -151,21 +151,29 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
); );
} }
if data.emails.is_some() { if !CONFIG.mail_enabled() && data.emails.is_some() {
err!("Sends with email verification is not supported"); 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()); let max_access_count = match data.max_access_count {
send.user_uuid = Some(user_id);
send.notes = data.notes;
send.max_access_count = match data.max_access_count {
Some(m) => Some(m.into_i32()?), Some(m) => Some(m.into_i32()?),
_ => None, _ => None,
}; };
send.expiration_date = data.expiration_date.map(|d| d.naive_utc());
send.disabled = data.disabled; let mut send = Send::new(
send.hide_email = data.hide_email; data.r#type,
send.atype = 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()); send.set_password(data.password.as_deref());
@ -636,14 +644,14 @@ async fn put_send(send_id: SendId, data: Json<SendData>, headers: Headers, conn:
let data: SendData = data.into_inner(); let data: SendData = data.into_inner();
enforce_disable_hide_email_policy(&data, &headers, &conn).await?; 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 { 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") 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?; update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?;
Ok(Json(send.to_json())) 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.expiration_date = data.expiration_date.map(|d| d.naive_utc());
send.hide_email = data.hide_email; send.hide_email = data.hide_email;
send.disabled = data.disabled; send.disabled = data.disabled;
send.emails = data.emails.map(|e| e.to_lowercase());
// Only change the value if it's present // Only change the value if it's present
if let Some(password) = data.password { if let Some(password) = data.password {

4
src/api/identity.rs

@ -115,6 +115,8 @@ async fn login(
let tokens = auth::SendTokens::generate_tokens( let tokens = auth::SendTokens::generate_tokens(
data.send_id.as_ref().unwrap(), data.send_id.as_ref().unwrap(),
data.password_hash_b64, data.password_hash_b64,
data.email.map(|e| e.to_lowercase()),
data.otp,
&client_header.ip, &client_header.ip,
&conn, &conn,
) )
@ -1161,6 +1163,8 @@ struct ConnectData {
// Needed for send access // Needed for send access
send_id: Option<SendId>, send_id: Option<SendId>,
password_hash_b64: Option<String>, password_hash_b64: Option<String>,
email: Option<String>,
otp: Option<String>,
} }
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult { fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
if value.is_none() { if value.is_none() {

47
src/auth/send.rs

@ -3,14 +3,17 @@ use chrono::{TimeDelta, Utc};
use rocket::request::{FromRequest, Outcome, Request}; use rocket::request::{FromRequest, Outcome, Request};
use crate::{ use crate::{
CONFIG,
api::ApiResult, api::ApiResult,
auth, auth,
auth::{BasicJwtClaims, ClientIp}, auth::{BasicJwtClaims, ClientIp},
crypto,
db::{ db::{
DbConn, DbConn,
models::{Send, SendId}, models::{SendId, SendOTP},
}, },
error::{Error, ErrorKind}, error::{Error, ErrorKind},
mail,
}; };
fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims { fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims {
@ -68,14 +71,18 @@ impl SendTokens {
pub async fn generate_tokens( pub async fn generate_tokens(
access_id: &str, access_id: &str,
password: Option<String>, password: Option<String>,
email: Option<String>,
otp: Option<String>,
ip: &ClientIp, ip: &ClientIp,
conn: &DbConn, conn: &DbConn,
) -> ApiResult<SendTokens> { ) -> ApiResult<SendTokens> {
let now = Utc::now().naive_utc();
let Some(send_id) = Self::as_send_id(access_id) else { 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); 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); 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 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); 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); 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() { if send.password_hash.is_some() {
match password { match password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }

4
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. /// 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. /// Defaults to daily. Set blank to disable this job.
purge_incomplete_sso_auth: String, false, def, "0 20 0 * * *".to_owned(); 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 /// General settings
@ -1724,6 +1727,7 @@ where
reg!("email/send_emergency_access_invite", ".html"); reg!("email/send_emergency_access_invite", ".html");
reg!("email/send_org_invite", ".html"); reg!("email/send_org_invite", ".html");
reg!("email/send_single_org_removed_from_org", ".html"); reg!("email/send_single_org_removed_from_org", ".html");
reg!("email/sends_otp", ".html");
reg!("email/smtp_test", ".html"); reg!("email/smtp_test", ".html");
reg!("email/sso_change_email", ".html"); reg!("email/sso_change_email", ".html");
reg!("email/twofactor_email", ".html"); reg!("email/twofactor_email", ".html");

2
src/db/models/mod.rs

@ -34,7 +34,7 @@ pub use self::organization::{
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
OrganizationId, OrganizationId,
}; };
pub use self::send::{Send, SendFileId, SendId, SendType}; pub use self::send::{Send, SendFileId, SendId, SendOTP, SendType};
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_duo_context::TwoFactorDuoContext;

159
src/db/models/send.rs

@ -1,6 +1,6 @@
use std::path::Path; use std::path::Path;
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, TimeDelta, Utc};
use data_encoding::BASE64URL_NOPAD; use data_encoding::BASE64URL_NOPAD;
use derive_more::{AsRef, Deref, Display, From}; use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*; use diesel::prelude::*;
@ -12,14 +12,17 @@ use crate::{
CONFIG, CONFIG,
api::EmptyResult, api::EmptyResult,
config::PathType, config::PathType,
db::{DbConn, schema::sends}, db::{
DbConn, DbPool,
schema::{sends, sends_otp},
},
error::MapResult, error::MapResult,
util::{LowerCase, NumberOrString, format_date}, util::{LowerCase, NumberOrString, format_date},
}; };
use super::{OrganizationId, User, UserId}; use super::{OrganizationId, User, UserId};
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
#[diesel(table_name = sends)] #[diesel(table_name = sends)]
#[diesel(treat_none_as_null = true)] #[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))] #[diesel(primary_key(uuid))]
@ -41,6 +44,7 @@ pub struct Send {
pub max_access_count: Option<i32>, pub max_access_count: Option<i32>,
pub access_count: i32, pub access_count: i32,
pub emails: Option<String>,
pub creation_date: NaiveDateTime, pub creation_date: NaiveDateTime,
pub revision_date: NaiveDateTime, pub revision_date: NaiveDateTime,
@ -60,7 +64,7 @@ pub enum SendType {
enum SendAuthType { enum SendAuthType {
#[allow(dead_code)] #[allow(dead_code)]
// Send requires email OTP verification // Send requires email OTP verification
Email = 0, // Not yet supported by Vaultwarden Email = 0,
// Send requires a password // Send requires a password
Password = 1, Password = 1,
// Send requires no auth // Send requires no auth
@ -68,17 +72,29 @@ enum SendAuthType {
} }
impl Send { 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<String>,
data: String,
akey: String,
max_access_count: Option<i32>,
emails: Option<String>,
expiration_date: Option<NaiveDateTime>,
deletion_date: NaiveDateTime,
disabled: bool,
hide_email: Option<bool>,
) -> Self {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
Self { Self {
uuid: SendId::from(crate::util::get_uuid()), uuid: SendId::from(crate::util::get_uuid()),
user_uuid: None, user_uuid: Some(user_uuid),
organization_uuid: None, organization_uuid: None,
name, name,
notes: None, notes,
atype, atype,
data, data,
akey, akey,
@ -86,16 +102,17 @@ impl Send {
password_salt: None, password_salt: None,
password_iter: None, password_iter: None,
max_access_count: None, max_access_count,
access_count: 0, access_count: 0,
emails: emails.map(|e| e.to_lowercase()),
creation_date: now, creation_date: now,
revision_date: now, revision_date: now,
expiration_date: None, expiration_date,
deletion_date, deletion_date,
disabled: false, disabled,
hide_email: None, hide_email,
} }
} }
@ -162,9 +179,10 @@ impl Send {
"maxAccessCount": self.max_access_count, "maxAccessCount": self.max_access_count,
"accessCount": self.access_count, "accessCount": self.access_count,
"password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)), "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, "disabled": self.disabled,
"hideEmail": self.hide_email.unwrap_or(false), "hideEmail": self.hide_email.unwrap_or(false),
"emails": self.emails,
"revisionDate": format_date(&self.revision_date), "revisionDate": format_date(&self.revision_date),
"expirationDate": self.expiration_date.as_ref().map(format_date), "expirationDate": self.expiration_date.as_ref().map(format_date),
@ -202,24 +220,16 @@ impl Send {
self.revision_date = Utc::now().naive_utc(); self.revision_date = Utc::now().naive_utc();
db_run! { conn: db_run! { conn:
sqlite, mysql { mysql {
match diesel::replace_into(sends::table) diesel::insert_into(sends::table)
.values(&*self) .values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn) .execute(conn)
{ .map_res("Error saving send")
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")
} }
postgresql { postgresql, sqlite {
diesel::insert_into(sends::table) diesel::insert_into(sends::table)
.values(&*self) .values(&*self)
.on_conflict(sends::uuid) .on_conflict(sends::uuid)
@ -374,3 +384,94 @@ impl AsRef<Path> for SendFileId {
Path::new(&self.0) 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 find_with_send(uuid: &SendId, email: Option<&String>, conn: &DbConn) -> Option<(Send, Option<Self>)> {
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<Self>)>::as_select())
.filter(sends::uuid.eq(uuid))
.first::<(Send, Option<Self>)>(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")
}
}
}

14
src/db/schema.rs

@ -144,6 +144,7 @@ table! {
password_iter -> Nullable<Integer>, password_iter -> Nullable<Integer>,
max_access_count -> Nullable<Integer>, max_access_count -> Nullable<Integer>,
access_count -> Integer, access_count -> Integer,
emails -> Nullable<Text>,
creation_date -> Timestamp, creation_date -> Timestamp,
revision_date -> Timestamp, revision_date -> Timestamp,
expiration_date -> Nullable<Timestamp>, expiration_date -> Nullable<Timestamp>,
@ -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! { table! {
twofactor (uuid) { twofactor (uuid) {
uuid -> Text, uuid -> Text,
@ -366,6 +378,7 @@ joinable!(folders_ciphers -> folders (folder_uuid));
joinable!(org_policies -> organizations (org_uuid)); joinable!(org_policies -> organizations (org_uuid));
joinable!(sends -> organizations (organization_uuid)); joinable!(sends -> organizations (organization_uuid));
joinable!(sends -> users (user_uuid)); joinable!(sends -> users (user_uuid));
joinable!(sends_otp -> sends (send_uuid));
joinable!(twofactor -> users (user_uuid)); joinable!(twofactor -> users (user_uuid));
joinable!(users_collections -> collections (collection_uuid)); joinable!(users_collections -> collections (collection_uuid));
joinable!(users_collections -> users (user_uuid)); joinable!(users_collections -> users (user_uuid));
@ -396,6 +409,7 @@ allow_tables_to_appear_in_same_query!(
org_policies, org_policies,
organizations, organizations,
sends, sends,
sends_otp,
sso_users, sso_users,
twofactor, twofactor,
users, users,

13
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 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 { async fn send_with_selected_transport(email: Message) -> EmptyResult {
if CONFIG.use_sendmail() { if CONFIG.use_sendmail() {
match sendmail_transport().send(email).await { match sendmail_transport().send(email).await {

7
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 // 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 // jobs that run more often than once a minute, so a default poll
// interval of 30 seconds should be sufficient. Users who want to // interval of 30 seconds should be sufficient. Users who want to

6
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 }}

16
src/static/templates/email/sends_otp.html.hbs

@ -0,0 +1,16 @@
Your Vaultwarden Sends Verification Code
<!---------------->
{{> email/email_header }}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
Your email verification code is: <b data-testid="2fa">{{token}}</b>
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
Use this code to access the shared secret in Vaultwarden.
</td>
</tr>
</table>
{{> email/email_footer }}
Loading…
Cancel
Save