mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2026-07-10 10:14:51 -06:00
Merge 43ad295210 into 4720cdbe86
This commit is contained in:
commit
d577e5c030
@ -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 ###
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE sends DROP COLUMN emails;
|
||||
DROP TABLE sends_otp;
|
||||
51
migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql
Normal file
51
migrations/mysql/2026-06-18-120000_add_sends_emails/up.sql
Normal 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;
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE sends DROP COLUMN emails;
|
||||
DROP TABLE sends_otp;
|
||||
@ -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;
|
||||
52
migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql
Normal file
52
migrations/sqlite/2026-06-18-120000_add_sends_emails/up.sql
Normal 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)
|
||||
);
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 */ }
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
13
src/mail.rs
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
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@ -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
|
||||
|
||||
6
src/static/templates/email/sends_otp.hbs
Normal file
6
src/static/templates/email/sends_otp.hbs
Normal 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 }}
|
||||
16
src/static/templates/email/sends_otp.html.hbs
Normal file
16
src/static/templates/email/sends_otp.html.hbs
Normal 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 }}
|
||||
Loading…
Reference in New Issue
Block a user