Browse Source

Add email verification for Sends

Allow Sends to be restricted to a list of email addresses. Recipients
request a one-time code by email through the `send_access` grant and
exchange it for an access token, matching the upstream flow.

- Store `auth_type` and `emails` on sends, with an additive migration
- One-time codes: single use, constant-time compared, invalidated after
  5 wrong attempts, 60s resend cooldown, 5 minute validity
- Unlisted emails get the same response as listed ones and no email is
  sent, so recipients can't be enumerated
- Access is gated on the stored auth type and fails closed when a Send
  doesn't hold the emails or password its auth type requires
- Shared create/update handling of authType; it is inferred when
  missing, like upstream, for clients that don't send it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pull/7861/head
ICantThinkOfAName-tech 5 days ago
parent
commit
c46c22139b
  1. 3
      migrations/mysql/2026-10-16-120000_add_send_email_verification/down.sql
  2. 15
      migrations/mysql/2026-10-16-120000_add_send_email_verification/up.sql
  3. 3
      migrations/postgresql/2026-10-16-120000_add_send_email_verification/down.sql
  4. 15
      migrations/postgresql/2026-10-16-120000_add_send_email_verification/up.sql
  5. 3
      migrations/sqlite/2026-10-16-120000_add_send_email_verification/down.sql
  6. 15
      migrations/sqlite/2026-10-16-120000_add_send_email_verification/up.sql
  7. 187
      src/api/core/sends.rs
  8. 4
      src/api/identity.rs
  9. 85
      src/auth/send.rs
  10. 1
      src/config.rs
  11. 2
      src/db/models/mod.rs
  12. 154
      src/db/models/send.rs
  13. 15
      src/db/schema.rs
  14. 13
      src/mail.rs
  15. 6
      src/static/templates/email/send_access_otp.hbs
  16. 16
      src/static/templates/email/send_access_otp.html.hbs

3
migrations/mysql/2026-10-16-120000_add_send_email_verification/down.sql

@ -0,0 +1,3 @@
DROP TABLE send_access_otps;
ALTER TABLE sends DROP COLUMN emails;
ALTER TABLE sends DROP COLUMN auth_type;

15
migrations/mysql/2026-10-16-120000_add_send_email_verification/up.sql

@ -0,0 +1,15 @@
ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2;
UPDATE sends SET auth_type = 1 WHERE password_hash IS NOT NULL;
ALTER TABLE sends ADD COLUMN emails TEXT;
CREATE TABLE send_access_otps (
send_uuid CHAR(36) NOT NULL,
email VARCHAR(255) NOT NULL,
code TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
creation_date DATETIME NOT NULL,
expiration_date DATETIME NOT NULL,
PRIMARY KEY (send_uuid, email),
FOREIGN KEY (send_uuid) REFERENCES sends (uuid) ON DELETE CASCADE
);

3
migrations/postgresql/2026-10-16-120000_add_send_email_verification/down.sql

@ -0,0 +1,3 @@
DROP TABLE send_access_otps;
ALTER TABLE sends DROP COLUMN emails;
ALTER TABLE sends DROP COLUMN auth_type;

15
migrations/postgresql/2026-10-16-120000_add_send_email_verification/up.sql

@ -0,0 +1,15 @@
ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2;
UPDATE sends SET auth_type = 1 WHERE password_hash IS NOT NULL;
ALTER TABLE sends ADD COLUMN emails TEXT;
CREATE TABLE send_access_otps (
send_uuid CHAR(36) NOT NULL,
email VARCHAR(255) NOT NULL,
code TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
creation_date TIMESTAMP NOT NULL,
expiration_date TIMESTAMP NOT NULL,
PRIMARY KEY (send_uuid, email),
FOREIGN KEY (send_uuid) REFERENCES sends (uuid) ON DELETE CASCADE
);

3
migrations/sqlite/2026-10-16-120000_add_send_email_verification/down.sql

@ -0,0 +1,3 @@
DROP TABLE send_access_otps;
ALTER TABLE sends DROP COLUMN emails;
ALTER TABLE sends DROP COLUMN auth_type;

15
migrations/sqlite/2026-10-16-120000_add_send_email_verification/up.sql

@ -0,0 +1,15 @@
ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2;
UPDATE sends SET auth_type = 1 WHERE password_hash IS NOT NULL;
ALTER TABLE sends ADD COLUMN emails TEXT;
CREATE TABLE send_access_otps (
send_uuid TEXT NOT NULL,
email TEXT NOT NULL,
code TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
creation_date DATETIME NOT NULL,
expiration_date DATETIME NOT NULL,
PRIMARY KEY (send_uuid, email),
FOREIGN KEY (send_uuid) REFERENCES sends (uuid) ON DELETE CASCADE
);

187
src/api/core/sends.rs

@ -1,7 +1,7 @@
use std::{path::Path, sync::LazyLock, time::Duration};
use chrono::{DateTime, TimeDelta, Utc};
use num_traits::ToPrimitive;
use num_traits::{FromPrimitive, ToPrimitive};
use rocket::{
form::Form,
fs::{NamedFile, TempFile},
@ -16,7 +16,7 @@ use crate::{
config::PathType,
db::{
DbConn, DbPool,
models::{Device, OrgPolicy, OrgPolicyType, Send, SendFileId, SendId, SendType, UserId},
models::{Device, OrgPolicy, OrgPolicyType, Send, SendAuthType, SendFileId, SendId, SendType, UserId},
},
util::{NumberOrString, save_temp_file},
};
@ -77,6 +77,7 @@ pub struct SendData {
deletion_date: DateTime<Utc>,
disabled: bool,
hide_email: Option<bool>,
auth_type: Option<i32>,
emails: Option<String>,
// Data field
@ -148,10 +149,6 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
);
}
if data.emails.is_some() {
err!("Sends with email verification is not supported");
}
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;
@ -164,11 +161,85 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
send.hide_email = data.hide_email;
send.atype = data.r#type;
send.set_password(data.password.as_deref());
apply_send_auth(&mut send, data.auth_type, data.emails.as_deref(), data.password)?;
enforce_mail_enabled_for_email_auth(&send)?;
Ok(send)
}
/// Splits a comma separated list of emails, trimming and lowercasing each entry and dropping empty ones.
/// Returns `None` when no address is left.
fn normalize_emails(emails: Option<&str>) -> Option<String> {
let emails: Vec<String> = emails?.split(',').map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()).collect();
(!emails.is_empty()).then(|| emails.join(","))
}
/// Applies the requested access control to a Send, used for both creation and updates.
///
/// When `authType` is missing (older clients) it is inferred from the other fields, like upstream does.
/// The stored `auth_type` is always derived from the resulting state, so a Send never claims a
/// protection it doesn't actually enforce.
///
/// Upstream: https://github.com/bitwarden/server/blob/main/src/Api/Tools/Models/Request/SendRequestModel.cs
fn apply_send_auth(
send: &mut Send,
auth_type: Option<i32>,
emails: Option<&str>,
password: Option<String>,
) -> EmptyResult {
let auth_type = match auth_type.map(SendAuthType::from_i32) {
Some(None) => err!("Invalid authType"),
Some(t) => t,
None => None,
};
let emails = normalize_emails(emails);
let password = password.filter(|p| !p.is_empty());
match auth_type {
Some(SendAuthType::Email) => {
let Some(emails) = emails else {
err!("Email verification requires at least one email address")
};
send.emails = Some(emails);
send.set_password(None);
}
Some(SendAuthType::Password) => {
send.emails = None;
// Editing a password protected Send doesn't resend the password, keep the existing one
if let Some(password) = password {
send.set_password(Some(&password));
} else if send.password_hash.is_none() {
err!("A password is required")
}
}
Some(SendAuthType::None) => {
send.emails = None;
send.set_password(None);
}
// Unlike upstream, an email protected Send is kept as is when no emails are provided, clients
// without `authType` don't know about emails so they can't have intentionally removed them.
None => {
if emails.is_some() {
send.emails = emails;
send.set_password(None);
} else if let Some(password) = password {
send.emails = None;
send.set_password(Some(&password));
}
}
}
send.auth_type = send.effective_auth_type() as i32;
Ok(())
}
fn enforce_mail_enabled_for_email_auth(send: &Send) -> EmptyResult {
if send.emails.is_some() && !CONFIG.mail_enabled() {
err!("Email verification for Sends requires email to be enabled on this server")
}
Ok(())
}
#[get("/sends")]
async fn get_sends(headers: Headers, conn: DbConn) -> Json<Value> {
let sends = Send::find_by_user(&headers.user.uuid, &conn);
@ -473,10 +544,6 @@ async fn put_send(send_id: SendId, data: Json<SendData>, headers: Headers, conn:
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()))
@ -528,10 +595,8 @@ async fn update_send_from_data(
send.hide_email = data.hide_email;
send.disabled = data.disabled;
// Only change the value if it's present
if let Some(password) = data.password {
send.set_password(Some(&password));
}
apply_send_auth(send, data.auth_type, data.emails.as_deref(), data.password)?;
enforce_mail_enabled_for_email_auth(send)?;
send.save(conn).await?;
if ut != UpdateType::None {
@ -568,6 +633,7 @@ async fn put_remove_password(send_id: SendId, headers: Headers, conn: DbConn, nt
};
send.set_password(None);
send.auth_type = send.effective_auth_type() as i32;
send.save(&conn).await?;
nt.send_send_update(
UpdateType::SyncSendUpdate,
@ -580,3 +646,94 @@ async fn put_remove_password(send_id: SendId, headers: Headers, conn: DbConn, nt
Ok(Json(send.to_json()))
}
#[cfg(test)]
mod tests {
use super::*;
fn new_send() -> Send {
Send::new(
SendType::Text as i32,
String::from("name"),
String::from("{}"),
String::from("key"),
Utc::now().naive_utc(),
)
}
const EMAIL: i32 = SendAuthType::Email as i32;
const PASSWORD: i32 = SendAuthType::Password as i32;
const NONE: i32 = SendAuthType::None as i32;
#[test]
fn test_normalize_emails() {
assert_eq!(normalize_emails(Some(" A@x.com, ,b@X.com ,")).as_deref(), Some("a@x.com,b@x.com"));
assert_eq!(normalize_emails(Some(" , ")), None);
assert_eq!(normalize_emails(Some("")), None);
assert_eq!(normalize_emails(None), None);
}
#[test]
fn test_email_auth_requires_an_email() {
let mut send = new_send();
assert!(apply_send_auth(&mut send, Some(EMAIL), Some(" , "), None).is_err());
assert!(apply_send_auth(&mut send, Some(EMAIL), None, None).is_err());
}
#[test]
fn test_email_auth_clears_password() {
let mut send = new_send();
apply_send_auth(&mut send, Some(PASSWORD), None, Some(String::from("secret"))).unwrap();
apply_send_auth(&mut send, Some(EMAIL), Some("A@x.com"), Some(String::from("secret"))).unwrap();
assert_eq!(send.auth_type, EMAIL);
assert_eq!(send.emails.as_deref(), Some("a@x.com"));
assert!(send.password_hash.is_none());
}
#[test]
fn test_password_auth() {
let mut send = new_send();
assert!(apply_send_auth(&mut send, Some(PASSWORD), None, None).is_err());
apply_send_auth(&mut send, Some(PASSWORD), Some("a@x.com"), Some(String::from("secret"))).unwrap();
assert_eq!(send.auth_type, PASSWORD);
assert!(send.emails.is_none());
// Edits don't resend the password, the existing one is kept
apply_send_auth(&mut send, Some(PASSWORD), None, None).unwrap();
assert_eq!(send.auth_type, PASSWORD);
assert!(send.check_password("secret"));
}
#[test]
fn test_none_auth_clears_everything() {
let mut send = new_send();
apply_send_auth(&mut send, Some(EMAIL), Some("a@x.com"), None).unwrap();
apply_send_auth(&mut send, Some(NONE), Some("a@x.com"), Some(String::from("secret"))).unwrap();
assert_eq!(send.auth_type, NONE);
assert!(send.emails.is_none());
assert!(send.password_hash.is_none());
}
#[test]
fn test_missing_auth_type_is_inferred() {
let mut send = new_send();
apply_send_auth(&mut send, None, None, Some(String::from("secret"))).unwrap();
assert_eq!(send.auth_type, PASSWORD);
apply_send_auth(&mut send, None, Some("a@x.com"), None).unwrap();
assert_eq!(send.auth_type, EMAIL);
assert!(send.password_hash.is_none());
// Clients without authType don't know about emails, an email protected Send stays protected
apply_send_auth(&mut send, None, None, None).unwrap();
assert_eq!(send.auth_type, EMAIL);
assert_eq!(send.emails.as_deref(), Some("a@x.com"));
}
#[test]
fn test_invalid_auth_type() {
let mut send = new_send();
assert!(apply_send_auth(&mut send, Some(42), None, None).is_err());
}
}

4
src/api/identity.rs

@ -105,6 +105,8 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
let tokens = auth::SendTokens::generate_tokens(
data.send_id.as_ref().unwrap(),
data.password_hash_b64,
data.email,
data.otp,
&client_header.ip,
&conn,
)
@ -1165,6 +1167,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() {

85
src/auth/send.rs

@ -1,16 +1,20 @@
use chrono::{TimeDelta, Utc};
use num_traits::FromPrimitive;
use rocket::request::{FromRequest, Outcome, Request};
use crate::{
CONFIG,
api::ApiResult,
auth,
auth::{BasicJwtClaims, ClientIp},
crypto,
db::{
DbConn,
models::{Send, SendId},
models::{Send, SendAccessOtp, SendAuthType, SendId},
},
error::{Error, ErrorKind},
mail,
};
fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims {
@ -45,7 +49,7 @@ impl SendTokens {
})
}
fn expected_error(msg: &str, error_type: &str) -> ApiResult<SendTokens> {
fn expected_error<T>(msg: &str, error_type: &str) -> ApiResult<T> {
let err = json!({
"kind": "expected_server",
"error": "invalid_request",
@ -55,7 +59,7 @@ impl SendTokens {
Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).silent())
}
fn invalid_error(msg: &str, error_type: &str, silent: bool) -> ApiResult<SendTokens> {
fn invalid_error<T>(msg: &str, error_type: &str, silent: bool) -> ApiResult<T> {
let err = json!({
"kind": "expected_server",
"error": "invalid_grant",
@ -68,6 +72,8 @@ 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> {
@ -89,8 +95,10 @@ impl SendTokens {
return Self::invalid_error(&format!("Send {send_id}, not accessible"), "send_id_invalid", true);
}
if send.password_hash.is_some() {
match password {
// Rely on the stored auth type and fail closed if the Send doesn't hold what it requires
match SendAuthType::from_i32(send.auth_type) {
Some(SendAuthType::Email) => Self::verify_email_otp(&send, email, otp, conn).await?,
Some(SendAuthType::Password) => match password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
Some(_) => {
return Self::invalid_error(
@ -100,6 +108,14 @@ impl SendTokens {
);
}
None => return Self::expected_error("Password required", "password_hash_b64_required"),
},
Some(SendAuthType::None) if send.password_hash.is_none() && send.emails.is_none() => {}
_ => {
return Self::invalid_error(
&format!("Send {send_id}, inconsistent access settings"),
"send_id_invalid",
false,
);
}
}
@ -109,6 +125,65 @@ impl SendTokens {
}
}
impl SendTokens {
/// Email verification: the first request (without `otp`) emails a code to the recipient, the
/// second one exchanges it for an access token.
///
/// Unlisted emails get the same response as listed ones and no email is sent, so the list of
/// recipients can't be enumerated.
///
/// Upstream: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs
async fn verify_email_otp(send: &Send, email: Option<String>, otp: Option<String>, conn: &DbConn) -> ApiResult<()> {
let Some(allowed_emails) = send.emails.as_deref() else {
return Self::invalid_error(&format!("Send {}, no emails to verify", send.uuid), "send_id_invalid", false);
};
if !CONFIG.mail_enabled() {
return Self::invalid_error(
&format!("Send {}, email verification without mail enabled", send.uuid),
"send_id_invalid",
false,
);
}
let Some(email) = email.map(|e| e.trim().to_lowercase()).filter(|e| !e.is_empty()) else {
return Self::expected_error("Email required", "email_required");
};
if !allowed_emails.split(',').any(|e| e == email) {
return Self::expected_error("Email and OTP required", "email_and_otp_required");
}
let stored = SendAccessOtp::find(&send.uuid, &email, conn).await;
let Some(code) = otp.map(|o| o.trim().to_owned()).filter(|o| !o.is_empty()) else {
if stored.as_ref().is_none_or(SendAccessOtp::can_resend) {
let code = crypto::generate_email_token(CONFIG.email_token_size());
let new_otp = SendAccessOtp::new(send.uuid.clone(), email.clone(), code.clone());
new_otp.save(conn).await?;
if let Err(e) = mail::send_send_access_otp(&email, &code).await {
new_otp.delete(conn).await.ok();
return Err(e);
}
}
return Self::expected_error("Email and OTP required", "email_and_otp_required");
};
let Some(stored) = stored else {
return Self::expected_error("Email and OTP required", "email_and_otp_required");
};
if stored.is_expired() || stored.attempts >= SendAccessOtp::MAX_ATTEMPTS {
stored.delete(conn).await?;
return Self::expected_error("Email and OTP required", "email_and_otp_required");
}
if !crypto::ct_eq(&code, &stored.code) {
stored.increment_attempts(conn).await?;
return Self::expected_error("Email and OTP required", "email_and_otp_required");
}
// Codes are single use
stored.delete(conn).await?;
Ok(())
}
}
pub struct SendHeaders {
pub send_id: SendId,
}

1
src/config.rs

@ -1736,6 +1736,7 @@ where
reg!("email/invite_confirmed", ".html");
reg!("email/new_device_logged_in", ".html");
reg!("email/protected_action", ".html");
reg!("email/send_access_otp", ".html");
reg!("email/pw_hint_none", ".html");
reg!("email/pw_hint_some", ".html");
reg!("email/recover_twofactor", ".html");

2
src/db/models/mod.rs

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

154
src/db/models/send.rs

@ -1,6 +1,6 @@
use std::path::Path;
use chrono::{NaiveDateTime, Utc};
use chrono::{NaiveDateTime, TimeDelta, Utc};
use data_encoding::BASE64URL_NOPAD;
use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*;
@ -12,7 +12,10 @@ use crate::{
CONFIG,
api::EmptyResult,
config::PathType,
db::{DbConn, schema::sends},
db::{
DbConn,
schema::{send_access_otps, sends},
},
error::MapResult,
util::{LowerCase, NumberOrString, format_date},
};
@ -49,6 +52,8 @@ pub struct Send {
pub disabled: bool,
pub hide_email: Option<bool>,
pub auth_type: i32,
pub emails: Option<String>,
}
#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)]
@ -57,10 +62,10 @@ pub enum SendType {
File = 1,
}
enum SendAuthType {
#[allow(dead_code)]
#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)]
pub enum SendAuthType {
// Send requires email OTP verification
Email = 0, // Not yet supported by Vaultwarden
Email = 0,
// Send requires a password
Password = 1,
// Send requires no auth
@ -96,6 +101,8 @@ impl Send {
disabled: false,
hide_email: None,
auth_type: SendAuthType::None as i32,
emails: None,
}
}
@ -115,6 +122,17 @@ impl Send {
}
}
/// The protection actually enforced, given the stored emails and password.
pub fn effective_auth_type(&self) -> SendAuthType {
if self.emails.is_some() {
SendAuthType::Email
} else if self.password_hash.is_some() {
SendAuthType::Password
} else {
SendAuthType::None
}
}
pub fn check_password(&self, password: &str) -> bool {
match (&self.password_hash, &self.password_salt, self.password_iter) {
(Some(hash), Some(salt), Some(iter)) => {
@ -166,7 +184,8 @@ impl Send {
"maxAccessCount": self.max_access_count,
"accessCount": self.access_count,
"password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)),
"authType": if self.password_hash.is_some() { SendAuthType::Password as i32 } else { SendAuthType::None as i32 },
"authType": self.auth_type,
"emails": self.emails,
"disabled": self.disabled,
"hideEmail": self.hide_email.unwrap_or(false),
@ -282,17 +301,20 @@ impl Send {
operator.delete_with(&self.uuid).recursive(true).await.ok();
}
SendAccessOtp::delete_all_by_send(&self.uuid, conn).await?;
conn.run(move |conn| {
diesel::delete(sends::table.filter(sends::uuid.eq(&self.uuid))).execute(conn).map_res("Error deleting send")
})
.await
}
/// Purge all sends that are past their deletion date.
/// Purge all sends that are past their deletion date, and expired access codes.
pub async fn purge(conn: &DbConn) {
for send in Self::find_by_past_deletion_date(conn).await {
send.delete(conn).await.ok();
}
SendAccessOtp::delete_expired(conn).await.ok();
}
pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> {
@ -361,6 +383,124 @@ impl Send {
}
}
/// A one-time code emailed to a recipient of an email verified Send.
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = send_access_otps)]
#[diesel(primary_key(send_uuid, email))]
pub struct SendAccessOtp {
pub send_uuid: SendId,
pub email: String,
pub code: String,
pub attempts: i32,
pub creation_date: NaiveDateTime,
pub expiration_date: NaiveDateTime,
}
impl SendAccessOtp {
/// How long a code stays valid.
const VALIDITY_MINUTES: i64 = 5;
/// Wrong guesses allowed before a code is discarded.
pub const MAX_ATTEMPTS: i32 = 5;
/// Minimum time before a new code can be requested, so the endpoint can't be used to spam a
/// recipient or keep invalidating the code they are about to type.
const RESEND_COOLDOWN_SECONDS: i64 = 60;
pub fn new(send_uuid: SendId, email: String, code: String) -> Self {
let now = Utc::now().naive_utc();
Self {
send_uuid,
email,
code,
attempts: 0,
creation_date: now,
expiration_date: now + TimeDelta::minutes(Self::VALIDITY_MINUTES),
}
}
pub fn is_expired(&self) -> bool {
Utc::now().naive_utc() >= self.expiration_date
}
pub fn can_resend(&self) -> bool {
self.is_expired()
|| Utc::now().naive_utc() >= self.creation_date + TimeDelta::seconds(Self::RESEND_COOLDOWN_SECONDS)
}
/// Stores the code, replacing any previous one for the same Send and email.
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
conn.transaction(|conn| {
diesel::delete(
send_access_otps::table
.filter(send_access_otps::send_uuid.eq(&self.send_uuid))
.filter(send_access_otps::email.eq(&self.email)),
)
.execute(conn)?;
diesel::insert_into(send_access_otps::table).values(self).execute(conn)
})
.map_res("Error saving send access otp")
})
.await
}
pub async fn increment_attempts(&self, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::update(
send_access_otps::table
.filter(send_access_otps::send_uuid.eq(&self.send_uuid))
.filter(send_access_otps::email.eq(&self.email)),
)
.set(send_access_otps::attempts.eq(send_access_otps::attempts + 1))
.execute(conn)
.map_res("Error updating send access otp")
})
.await
}
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::delete(
send_access_otps::table
.filter(send_access_otps::send_uuid.eq(&self.send_uuid))
.filter(send_access_otps::email.eq(&self.email)),
)
.execute(conn)
.map_res("Error deleting send access otp")
})
.await
}
pub async fn delete_all_by_send(send_uuid: &SendId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::delete(send_access_otps::table.filter(send_access_otps::send_uuid.eq(send_uuid)))
.execute(conn)
.map_res("Error deleting send access otps")
})
.await
}
pub async fn delete_expired(conn: &DbConn) -> EmptyResult {
let now = Utc::now().naive_utc();
conn.run(move |conn| {
diesel::delete(send_access_otps::table.filter(send_access_otps::expiration_date.le(now)))
.execute(conn)
.map_res("Error deleting expired send access otps")
})
.await
}
pub async fn find(send_uuid: &SendId, email: &str, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| {
send_access_otps::table
.filter(send_access_otps::send_uuid.eq(send_uuid))
.filter(send_access_otps::email.eq(email))
.first::<Self>(conn)
.ok()
})
.await
}
}
#[derive(
Clone,
Debug,

15
src/db/schema.rs

@ -151,6 +151,19 @@ table! {
deletion_date -> Timestamp,
disabled -> Bool,
hide_email -> Nullable<Bool>,
auth_type -> Integer,
emails -> Nullable<Text>,
}
}
table! {
send_access_otps (send_uuid, email) {
send_uuid -> Text,
email -> Text,
code -> Text,
attempts -> Integer,
creation_date -> Timestamp,
expiration_date -> Timestamp,
}
}
@ -368,6 +381,7 @@ joinable!(folders_ciphers -> folders (folder_uuid));
joinable!(org_policies -> organizations (org_uuid));
joinable!(sends -> organizations (organization_uuid));
joinable!(sends -> users (user_uuid));
joinable!(send_access_otps -> sends (send_uuid));
joinable!(twofactor -> users (user_uuid));
joinable!(users_collections -> collections (collection_uuid));
joinable!(users_collections -> users (user_uuid));
@ -398,6 +412,7 @@ allow_tables_to_appear_in_same_query!(
org_policies,
organizations,
sends,
send_access_otps,
sso_users,
twofactor,
users,

13
src/mail.rs

@ -671,6 +671,19 @@ pub async fn send_admin_account_recovery(
send_email(address, &subject, body_html, body_text).await
}
pub async fn send_send_access_otp(address: &str, token: &str) -> EmptyResult {
let (subject, body_html, body_text) = get_text(
"email/send_access_otp",
json!({
"url": CONFIG.domain(),
"img_src": CONFIG._smtp_img_src(),
"token": token,
}),
)?;
send_email(address, &subject, body_html, body_text).await
}
pub async fn send_protected_action_token(address: &str, token: &str) -> EmptyResult {
let (subject, body_html, body_text) = get_text(
"email/protected_action",

6
src/static/templates/email/send_access_otp.hbs

@ -0,0 +1,6 @@
Your Vaultwarden Send Verification Code
<!---------------->
Your email verification code is: {{token}}
Use this code to view the Send that was shared with you. It expires in 5 minutes.
{{> email/email_footer_text }}

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

@ -0,0 +1,16 @@
Your Vaultwarden Send 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>{{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 view the Send that was shared with you. It expires in 5 minutes.
</td>
</tr>
</table>
{{> email/email_footer }}
Loading…
Cancel
Save