This commit is contained in:
Timshel 2026-07-07 16:28:30 +00:00 committed by GitHub
commit d577e5c030
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 494 additions and 97 deletions

View File

@ -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 ###

View File

@ -0,0 +1,2 @@
ALTER TABLE sends DROP COLUMN emails;
DROP TABLE sends_otp;

View File

@ -0,0 +1,51 @@
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)
);
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;

View File

@ -0,0 +1,2 @@
ALTER TABLE sends DROP COLUMN emails;
DROP TABLE sends_otp;

View File

@ -0,0 +1,20 @@
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;

View File

@ -0,0 +1,52 @@
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,
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)
);

View File

@ -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();
});

View File

@ -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();

View File

@ -151,21 +151,29 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
);
}
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.unwrap_or(false),
);
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();
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()))
@ -657,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")
}
@ -692,8 +700,9 @@ 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());
// Only change the value if it's present
if let Some(password) = data.password {

View File

@ -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<SendId>,
password_hash_b64: Option<String>,
email: Option<String>,
otp: Option<String>,
}
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
if value.is_none() {

View File

@ -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,

View File

@ -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,

View File

@ -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<String>,
email: Option<String>,
otp: Option<String>,
ip: &ClientIp,
conn: &DbConn,
) -> ApiResult<SendTokens> {
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 */ }

View File

@ -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");

View File

@ -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};

View File

@ -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,23 +8,25 @@ 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};
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))]
pub struct Send {
pub uuid: SendId,
pub user_uuid: Option<UserId>,
pub organization_uuid: Option<OrganizationId>,
pub user_uuid: UserId,
pub name: String,
pub notes: Option<String>,
@ -38,6 +40,7 @@ pub struct Send {
pub max_access_count: Option<i32>,
pub access_count: i32,
pub emails: Option<String>,
pub creation_date: NaiveDateTime,
pub revision_date: NaiveDateTime,
@ -45,7 +48,7 @@ pub struct Send {
pub deletion_date: NaiveDateTime,
pub disabled: bool,
pub hide_email: Option<bool>,
pub hide_email: bool,
}
#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)]
@ -57,7 +60,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 +68,28 @@ 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<String>,
data: String,
akey: String,
max_access_count: Option<i32>,
emails: Option<String>,
expiration_date: Option<NaiveDateTime>,
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,
notes,
atype,
data,
akey,
@ -83,16 +97,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,
}
}
@ -122,19 +137,13 @@ impl Send {
}
pub async fn creator_identifier(&self, conn: &DbConn) -> Option<String> {
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 {
@ -159,9 +168,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),
"hideEmail": self.hide_email,
"emails": self.emails,
"revisionDate": format_date(&self.revision_date),
"expirationDate": self.expiration_date.as_ref().map(format_date),
@ -199,24 +209,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)
@ -250,14 +252,8 @@ impl Send {
}
pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> {
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 {
@ -319,13 +315,6 @@ impl Send {
Some(total)
}
pub async fn find_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
sends::table.filter(sends::organization_uuid.eq(org_uuid)).load::<Self>(conn).expect("Error loading sends")
})
.await
}
pub async fn find_by_past_deletion_date(conn: &DbConn) -> Vec<Self> {
let now = Utc::now().naive_utc();
conn.run(move |conn| {
@ -379,3 +368,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<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")
}
}
}

View File

@ -132,8 +132,7 @@ table! {
table! {
sends (uuid) {
uuid -> Text,
user_uuid -> Nullable<Text>,
organization_uuid -> Nullable<Text>,
user_uuid -> Text,
name -> Text,
notes -> Nullable<Text>,
atype -> Integer,
@ -144,12 +143,24 @@ table! {
password_iter -> Nullable<Integer>,
max_access_count -> Nullable<Integer>,
access_count -> Integer,
emails -> Nullable<Text>,
creation_date -> Timestamp,
revision_date -> Timestamp,
expiration_date -> Nullable<Timestamp>,
deletion_date -> Timestamp,
disabled -> Bool,
hide_email -> Nullable<Bool>,
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,
}
}
@ -364,8 +375,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));
@ -396,6 +407,7 @@ allow_tables_to_appear_in_same_query!(
org_policies,
organizations,
sends,
sends_otp,
sso_users,
twofactor,
users,

View File

@ -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 {

View File

@ -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

View File

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

View File

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