From c46c22139b6d8797d8fb8b3e68826d1769bd6a40 Mon Sep 17 00:00:00 2001 From: ICantThinkOfAName-tech Date: Fri, 16 Oct 2026 20:16:31 +0200 Subject: [PATCH] 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 --- .../down.sql | 3 + .../up.sql | 15 ++ .../down.sql | 3 + .../up.sql | 15 ++ .../down.sql | 3 + .../up.sql | 15 ++ src/api/core/sends.rs | 187 ++++++++++++++++-- src/api/identity.rs | 4 + src/auth/send.rs | 85 +++++++- src/config.rs | 1 + src/db/models/mod.rs | 2 +- src/db/models/send.rs | 154 ++++++++++++++- src/db/schema.rs | 15 ++ src/mail.rs | 13 ++ .../templates/email/send_access_otp.hbs | 6 + .../templates/email/send_access_otp.html.hbs | 16 ++ 16 files changed, 509 insertions(+), 28 deletions(-) create mode 100644 migrations/mysql/2026-10-16-120000_add_send_email_verification/down.sql create mode 100644 migrations/mysql/2026-10-16-120000_add_send_email_verification/up.sql create mode 100644 migrations/postgresql/2026-10-16-120000_add_send_email_verification/down.sql create mode 100644 migrations/postgresql/2026-10-16-120000_add_send_email_verification/up.sql create mode 100644 migrations/sqlite/2026-10-16-120000_add_send_email_verification/down.sql create mode 100644 migrations/sqlite/2026-10-16-120000_add_send_email_verification/up.sql create mode 100644 src/static/templates/email/send_access_otp.hbs create mode 100644 src/static/templates/email/send_access_otp.html.hbs diff --git a/migrations/mysql/2026-10-16-120000_add_send_email_verification/down.sql b/migrations/mysql/2026-10-16-120000_add_send_email_verification/down.sql new file mode 100644 index 00000000..24dc2a84 --- /dev/null +++ b/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; diff --git a/migrations/mysql/2026-10-16-120000_add_send_email_verification/up.sql b/migrations/mysql/2026-10-16-120000_add_send_email_verification/up.sql new file mode 100644 index 00000000..79e76ed9 --- /dev/null +++ b/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 +); diff --git a/migrations/postgresql/2026-10-16-120000_add_send_email_verification/down.sql b/migrations/postgresql/2026-10-16-120000_add_send_email_verification/down.sql new file mode 100644 index 00000000..24dc2a84 --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-10-16-120000_add_send_email_verification/up.sql b/migrations/postgresql/2026-10-16-120000_add_send_email_verification/up.sql new file mode 100644 index 00000000..bc5ff962 --- /dev/null +++ b/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 +); diff --git a/migrations/sqlite/2026-10-16-120000_add_send_email_verification/down.sql b/migrations/sqlite/2026-10-16-120000_add_send_email_verification/down.sql new file mode 100644 index 00000000..24dc2a84 --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-10-16-120000_add_send_email_verification/up.sql b/migrations/sqlite/2026-10-16-120000_add_send_email_verification/up.sql new file mode 100644 index 00000000..52d20307 --- /dev/null +++ b/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 +); diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 2a604b74..192cc52c 100644 --- a/src/api/core/sends.rs +++ b/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, disabled: bool, hide_email: Option, + auth_type: Option, emails: Option, // Data field @@ -148,10 +149,6 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { ); } - 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.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 { + let emails: Vec = 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, + emails: Option<&str>, + password: Option, +) -> 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 { let sends = Send::find_by_user(&headers.user.uuid, &conn); @@ -473,10 +544,6 @@ async fn put_send(send_id: SendId, data: Json, 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()); + } +} diff --git a/src/api/identity.rs b/src/api/identity.rs index b61258cf..6ff70057 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -105,6 +105,8 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo let tokens = auth::SendTokens::generate_tokens( data.send_id.as_ref().unwrap(), data.password_hash_b64, + data.email, + data.otp, &client_header.ip, &conn, ) @@ -1165,6 +1167,8 @@ struct ConnectData { // Needed for send access send_id: Option, password_hash_b64: Option, + email: Option, + otp: Option, } fn check_is_some(value: Option<&T>, msg: &str) -> EmptyResult { if value.is_none() { diff --git a/src/auth/send.rs b/src/auth/send.rs index 518b7098..29c8e976 100644 --- a/src/auth/send.rs +++ b/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 { + fn expected_error(msg: &str, error_type: &str) -> ApiResult { 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 { + fn invalid_error(msg: &str, error_type: &str, silent: bool) -> ApiResult { 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, + email: Option, + otp: Option, ip: &ClientIp, conn: &DbConn, ) -> ApiResult { @@ -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, otp: Option, 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, } diff --git a/src/config.rs b/src/config.rs index a56748f4..92f7ff6c 100644 --- a/src/config.rs +++ b/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"); diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0e4073a5..2ee6550c 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -34,7 +34,7 @@ pub use self::organization::{ Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, OrganizationId, }; -pub use self::send::{Send, SendFileId, SendId, SendType}; +pub use self::send::{Send, 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; diff --git a/src/db/models/send.rs b/src/db/models/send.rs index 721378f3..81b3a2ae 100644 --- a/src/db/models/send.rs +++ b/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, + pub auth_type: i32, + pub emails: Option, } #[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 { @@ -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 { + 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::(conn) + .ok() + }) + .await + } +} + #[derive( Clone, Debug, diff --git a/src/db/schema.rs b/src/db/schema.rs index ae1e3216..d651b430 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -151,6 +151,19 @@ table! { deletion_date -> Timestamp, disabled -> Bool, hide_email -> Nullable, + auth_type -> Integer, + emails -> Nullable, + } +} + +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, diff --git a/src/mail.rs b/src/mail.rs index 2efb8d70..4f0c1537 100644 --- a/src/mail.rs +++ b/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", diff --git a/src/static/templates/email/send_access_otp.hbs b/src/static/templates/email/send_access_otp.hbs new file mode 100644 index 00000000..58c23559 --- /dev/null +++ b/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 }} diff --git a/src/static/templates/email/send_access_otp.html.hbs b/src/static/templates/email/send_access_otp.html.hbs new file mode 100644 index 00000000..8e03b9f8 --- /dev/null +++ b/src/static/templates/email/send_access_otp.html.hbs @@ -0,0 +1,16 @@ +Your Vaultwarden Send Verification Code + +{{> email/email_header }} + + + + + + + +
+ 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 }}