vw_small

Hardened fork of Vaultwarden (https://github.com/dani-garcia/vaultwarden) with fewer features.
git clone https://git.philomathiclife.com/repos/vw_small
Log | Files | Refs | README

commit 5d7715e96361acfed8a4eefbf6434796d1758892
parent 891178d5d5fd4421e4214d182f00a7fa5fd39f00
Author: Zack Newman <zack@philomathiclife.com>
Date:   Mon, 13 Nov 2023 09:38:33 -0700

protected actions merge

Diffstat:
Msrc/api/core/accounts.rs | 36++++++++++++++----------------------
Msrc/api/core/ciphers.rs | 12++++--------
Msrc/api/core/organizations.rs | 29++++++++++++-----------------
Msrc/api/core/two_factor/authenticator.rs | 24+++++++++++-------------
Msrc/api/core/two_factor/mod.rs | 32+++++++++++++++++++-------------
Asrc/api/core/two_factor/protected_actions.rs | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/api/core/two_factor/webauthn.rs | 50++++++++++++++++++++++----------------------------
Msrc/api/mod.rs | 28++++++++++++++++++++++++++--
Msrc/config.rs | 9+++++----
Msrc/db/models/two_factor.rs | 3+++
Msrc/mail.rs | 13+++++++++++++
Asrc/static/templates/email/protected_action.hbs | 6++++++
Asrc/static/templates/email/protected_action.html.hbs | 16++++++++++++++++
13 files changed, 293 insertions(+), 107 deletions(-)

diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs @@ -4,8 +4,8 @@ use serde_json::Value; use crate::{ api::{ - AnonymousNotify, EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, - UpdateType, + AnonymousNotify, EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, + PasswordOrOtpData, UpdateType, }, auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers}, crypto, @@ -532,17 +532,15 @@ async fn post_rotatekey( #[post("/accounts/security-stamp", data = "<data>")] async fn post_sstamp( - data: JsonUpcase<PasswordData>, + data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - let data: PasswordData = data.into_inner().data; + let data: PasswordOrOtpData = data.into_inner().data; let mut user = headers.user; - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password") - } + data.validate(&user, true, &mut conn).await?; Device::delete_all_by_user(&user.uuid, &mut conn).await?; user.reset_security_stamp(); @@ -770,7 +768,7 @@ async fn post_delete_recover_token( #[post("/accounts/delete", data = "<data>")] async fn post_delete_account( - data: JsonUpcase<PasswordData>, + data: JsonUpcase<PasswordOrOtpData>, headers: Headers, conn: DbConn, ) -> EmptyResult { @@ -779,17 +777,13 @@ async fn post_delete_account( #[delete("/accounts", data = "<data>")] async fn delete_account( - data: JsonUpcase<PasswordData>, + data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn, ) -> EmptyResult { - let data: PasswordData = data.into_inner().data; + let data: PasswordOrOtpData = data.into_inner().data; let user = headers.user; - - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password") - } - + data.validate(&user, true, &mut conn).await?; user.delete(&mut conn).await } @@ -907,19 +901,17 @@ fn verify_password(data: JsonUpcase<SecretVerificationRequest>, headers: Headers } async fn _api_key( - data: JsonUpcase<SecretVerificationRequest>, + data: JsonUpcase<PasswordOrOtpData>, rotate: bool, headers: Headers, mut conn: DbConn, ) -> JsonResult { use crate::util::format_date; - let data: SecretVerificationRequest = data.into_inner().data; + let data: PasswordOrOtpData = data.into_inner().data; let mut user = headers.user; - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password") - } + data.validate(&user, true, &mut conn).await?; if rotate || user.api_key.is_none() { user.api_key = Some(crypto::generate_api_key()); @@ -935,7 +927,7 @@ async fn _api_key( #[post("/accounts/api-key", data = "<data>")] async fn api_key( - data: JsonUpcase<SecretVerificationRequest>, + data: JsonUpcase<PasswordOrOtpData>, headers: Headers, conn: DbConn, ) -> JsonResult { @@ -944,7 +936,7 @@ async fn api_key( #[post("/accounts/rotate-api-key", data = "<data>")] async fn rotate_api_key( - data: JsonUpcase<SecretVerificationRequest>, + data: JsonUpcase<PasswordOrOtpData>, headers: Headers, conn: DbConn, ) -> JsonResult { diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs @@ -1,6 +1,6 @@ use super::folders::FolderData; use crate::{ - api::{self, EmptyResult, JsonResult, JsonUpcase, Notify, PasswordData, UpdateType}, + api::{self, EmptyResult, JsonResult, JsonUpcase, Notify, PasswordOrOtpData, UpdateType}, auth::Headers, crypto, db::{models::*, DbConn}, @@ -1671,19 +1671,15 @@ struct OrganizationId { #[post("/ciphers/purge?<organization..>", data = "<data>")] async fn delete_all( organization: Option<OrganizationId>, - data: JsonUpcase<PasswordData>, + data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - let data: PasswordData = data.into_inner().data; - let password_hash = data.MasterPasswordHash; - + let data: PasswordOrOtpData = data.into_inner().data; let mut user = headers.user; - if !user.check_valid_password(&password_hash) { - err!("Invalid password") - } + data.validate(&user, true, &mut conn).await?; match organization { Some(org_data) => { diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs @@ -2,7 +2,7 @@ use crate::{ api::{ core::{CipherSyncData, CipherSyncType}, EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, JsonVec, Notify, NumberOrString, - PasswordData, UpdateType, + PasswordOrOtpData, UpdateType, }, auth::{ decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OwnerHeaders, @@ -199,16 +199,13 @@ async fn create_organization( #[delete("/organizations/<org_id>", data = "<data>")] async fn delete_organization( org_id: &str, - data: JsonUpcase<PasswordData>, + data: JsonUpcase<PasswordOrOtpData>, headers: OwnerHeaders, mut conn: DbConn, ) -> EmptyResult { - let data: PasswordData = data.into_inner().data; - let password_hash = data.MasterPasswordHash; + let data: PasswordOrOtpData = data.into_inner().data; - if !headers.user.check_valid_password(&password_hash) { - err!("Invalid password") - } + data.validate(&headers.user, true, &mut conn).await?; match Organization::find_by_uuid(org_id, &mut conn).await { None => err!("Organization not found"), @@ -219,7 +216,7 @@ async fn delete_organization( #[post("/organizations/<org_id>/delete", data = "<data>")] async fn post_delete_organization( org_id: &str, - data: JsonUpcase<PasswordData>, + data: JsonUpcase<PasswordOrOtpData>, headers: OwnerHeaders, conn: DbConn, ) -> EmptyResult { @@ -2666,18 +2663,16 @@ async fn get_org_export(org_id: &str, headers: AdminHeaders, mut conn: DbConn) - async fn _api_key( org_id: &str, - data: JsonUpcase<PasswordData>, + data: JsonUpcase<PasswordOrOtpData>, rotate: bool, headers: AdminHeaders, - conn: DbConn, + mut conn: DbConn, ) -> JsonResult { - let data: PasswordData = data.into_inner().data; + let data: PasswordOrOtpData = data.into_inner().data; let user = headers.user; - // Validate the admin users password - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password") - } + // Validate the admin users password/otp + data.validate(&user, true, &mut conn).await?; let org_api_key = match OrganizationApiKey::find_by_org_uuid(org_id, &conn).await { Some(mut org_api_key) => { @@ -2712,7 +2707,7 @@ async fn _api_key( #[post("/organizations/<org_id>/api-key", data = "<data>")] async fn api_key( org_id: &str, - data: JsonUpcase<PasswordData>, + data: JsonUpcase<PasswordOrOtpData>, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { @@ -2722,7 +2717,7 @@ async fn api_key( #[post("/organizations/<org_id>/rotate-api-key", data = "<data>")] async fn rotate_api_key( org_id: &str, - data: JsonUpcase<PasswordData>, + data: JsonUpcase<PasswordOrOtpData>, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { diff --git a/src/api/core/two_factor/authenticator.rs b/src/api/core/two_factor/authenticator.rs @@ -3,7 +3,7 @@ use rocket::serde::json::Json; use rocket::Route; use crate::{ - api::{EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData}, + api::{EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordOrOtpData}, auth::{ClientIp, Headers}, crypto, db::{ @@ -24,17 +24,13 @@ pub fn routes() -> Vec<Route> { #[post("/two-factor/get-authenticator", data = "<data>")] async fn generate_authenticator( - data: JsonUpcase<PasswordData>, + data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn, ) -> JsonResult { - let data: PasswordData = data.into_inner().data; + let data: PasswordOrOtpData = data.into_inner().data; let user = headers.user; - - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password"); - } - + data.validate(&user, false, &mut conn).await?; let type_ = TwoFactorType::Authenticator as i32; let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await; @@ -42,7 +38,6 @@ async fn generate_authenticator( Some(tf) => (true, tf.data), _ => (false, crypto::encode_random_bytes::<20>(BASE32)), }; - Ok(Json(json!({ "Enabled": enabled, "Key": key, @@ -53,9 +48,10 @@ async fn generate_authenticator( #[derive(Deserialize, Debug)] #[allow(non_snake_case)] struct EnableAuthenticatorData { - MasterPasswordHash: String, Key: String, Token: NumberOrString, + MasterPasswordHash: Option<String>, + Otp: Option<String>, } #[post("/two-factor/authenticator", data = "<data>")] @@ -65,14 +61,16 @@ async fn activate_authenticator( mut conn: DbConn, ) -> JsonResult { let data: EnableAuthenticatorData = data.into_inner().data; - let password_hash = data.MasterPasswordHash; let key = data.Key; let token = data.Token.into_string(); let user = headers.user; - if !user.check_valid_password(&password_hash) { - err!("Invalid password"); + PasswordOrOtpData { + MasterPasswordHash: data.MasterPasswordHash, + Otp: data.Otp, } + .validate(&user, true, &mut conn) + .await?; // Validate key as base32 and 20 bytes length let decoded_key: Vec<u8> = match BASE32.decode(key.as_bytes()) { diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs @@ -1,14 +1,14 @@ use crate::{ - api::{JsonResult, JsonUpcase, NumberOrString, PasswordData}, + api::{JsonResult, JsonUpcase, NumberOrString, PasswordOrOtpData}, auth::{ClientHeaders, Headers}, db::{models::*, DbConn}, mail, CONFIG, }; +pub mod authenticator; +pub mod protected_actions; use rocket::serde::json::Json; use rocket::Route; use serde_json::Value; - -pub mod authenticator; pub mod webauthn; pub fn routes() -> Vec<Route> { @@ -22,8 +22,8 @@ pub fn routes() -> Vec<Route> { ]; routes.append(&mut authenticator::routes()); + routes.append(&mut protected_actions::routes()); routes.append(&mut webauthn::routes()); - routes } @@ -40,13 +40,15 @@ async fn get_twofactor(headers: Headers, mut conn: DbConn) -> Json<Value> { } #[post("/two-factor/get-recover", data = "<data>")] -fn get_recover(data: JsonUpcase<PasswordData>, headers: Headers) -> JsonResult { - let data: PasswordData = data.into_inner().data; +async fn get_recover( + data: JsonUpcase<PasswordOrOtpData>, + headers: Headers, + mut conn: DbConn, +) -> JsonResult { + let data: PasswordOrOtpData = data.into_inner().data; let user = headers.user; - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password"); - } + data.validate(&user, true, &mut conn).await?; Ok(Json(json!({ "Code": user.totp_recover, @@ -99,7 +101,8 @@ async fn recover( #[derive(Deserialize)] #[allow(non_snake_case)] struct DisableTwoFactorData { - MasterPasswordHash: String, + MasterPasswordHash: Option<String>, + Otp: Option<String>, Type: NumberOrString, } @@ -110,12 +113,15 @@ async fn disable_twofactor( mut conn: DbConn, ) -> JsonResult { let data: DisableTwoFactorData = data.into_inner().data; - let password_hash = data.MasterPasswordHash; let user = headers.user; - if !user.check_valid_password(&password_hash) { - err!("Invalid password"); + // Delete directly after a valid token has been provided + PasswordOrOtpData { + MasterPasswordHash: data.MasterPasswordHash, + Otp: data.Otp, } + .validate(&user, true, &mut conn) + .await?; let type_ = data.Type.into_i32()?; diff --git a/src/api/core/two_factor/protected_actions.rs b/src/api/core/two_factor/protected_actions.rs @@ -0,0 +1,142 @@ +use chrono::{Duration, NaiveDateTime, Utc}; +use rocket::Route; + +use crate::{ + api::{EmptyResult, JsonUpcase}, + auth::Headers, + crypto, + db::{ + models::{TwoFactor, TwoFactorType}, + DbConn, + }, + error::{Error, MapResult}, + mail, CONFIG, +}; + +pub fn routes() -> Vec<Route> { + routes![request_otp, verify_otp] +} + +/// Data stored in the TwoFactor table in the db +#[derive(Serialize, Deserialize, Debug)] +pub struct ProtectedActionData { + /// Token issued to validate the protected action + pub token: String, + /// UNIX timestamp of token issue. + pub token_sent: i64, + // The total amount of attempts + pub attempts: u8, +} + +impl ProtectedActionData { + pub fn new(token: String) -> Self { + Self { + token, + token_sent: Utc::now().naive_utc().timestamp(), + attempts: 0, + } + } + + pub fn to_json(&self) -> String { + serde_json::to_string(&self).unwrap() + } + + pub fn from_json(string: &str) -> Result<Self, Error> { + let res: Result<Self, crate::serde_json::Error> = serde_json::from_str(string); + match res { + Ok(x) => Ok(x), + Err(_) => err!("Could not decode ProtectedActionData from string"), + } + } + + pub fn add_attempt(&mut self) { + self.attempts += 1; + } +} + +#[post("/accounts/request-otp")] +async fn request_otp(headers: Headers, mut conn: DbConn) -> EmptyResult { + if !CONFIG.mail_enabled() { + err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device."); + } + + let user = headers.user; + + // Only one Protected Action per user is allowed to take place, delete the previous one + if let Some(pa) = + TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &mut conn).await + { + pa.delete(&mut conn).await?; + } + + let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); + let pa_data = ProtectedActionData::new(generated_token); + + // Uses EmailVerificationChallenge as type to show that it's not verified yet. + let twofactor = TwoFactor::new(user.uuid, TwoFactorType::ProtectedActions, pa_data.to_json()); + twofactor.save(&mut conn).await?; + + mail::send_protected_action_token(&user.email, &pa_data.token).await?; + + Ok(()) +} + +#[derive(Deserialize, Serialize, Debug)] +#[allow(non_snake_case)] +struct ProtectedActionVerify { + OTP: String, +} + +#[post("/accounts/verify-otp", data = "<data>")] +async fn verify_otp(data: JsonUpcase<ProtectedActionVerify>, headers: Headers, mut conn: DbConn) -> EmptyResult { + if !CONFIG.mail_enabled() { + err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device."); + } + + let user = headers.user; + let data: ProtectedActionVerify = data.into_inner().data; + + // Delete the token after one validation attempt + // This endpoint only gets called for the vault export, and doesn't need a second attempt + validate_protected_action_otp(&data.OTP, &user.uuid, true, &mut conn).await +} + +pub async fn validate_protected_action_otp( + otp: &str, + user_uuid: &str, + delete_if_valid: bool, + conn: &mut DbConn, +) -> EmptyResult { + let pa = TwoFactor::find_by_user_and_type(user_uuid, TwoFactorType::ProtectedActions as i32, conn) + .await + .map_res("Protected action token not found, try sending the code again or restart the process")?; + let mut pa_data = ProtectedActionData::from_json(&pa.data)?; + + pa_data.add_attempt(); + // Delete the token after x attempts if it has been used too many times + // We use the 6, which should be more then enough for invalid attempts and multiple valid checks + if pa_data.attempts > 6 { + pa.delete(conn).await?; + err!("Token has expired") + } + + // Check if the token has expired (Using the email 2fa expiration time) + let date = + NaiveDateTime::from_timestamp_opt(pa_data.token_sent, 0).expect("Protected Action token timestamp invalid."); + let max_time = CONFIG.email_expiration_time() as i64; + if date + Duration::seconds(max_time) < Utc::now().naive_utc() { + pa.delete(conn).await?; + err!("Token has expired") + } + + if !crypto::ct_eq(&pa_data.token, otp) { + pa.save(conn).await?; + err!("Token is invalid") + } + + if delete_if_valid { + pa.delete(conn).await?; + } + + Ok(()) +} diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs @@ -7,7 +7,7 @@ use webauthn_rs::{ }; use crate::{ - api::{EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData}, + api::{EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordOrOtpData}, auth::Headers, db::{ models::{EventType, TwoFactor, TwoFactorType}, @@ -113,28 +113,23 @@ impl WebauthnRegistration { #[post("/two-factor/get-webauthn", data = "<data>")] async fn get_webauthn( - data: JsonUpcase<PasswordData>, + data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn, ) -> JsonResult { if !CONFIG.domain_set() { err!("`DOMAIN` environment variable is not set. Webauthn disabled") } + let data: PasswordOrOtpData = data.into_inner().data; + let user = headers.user; - if !headers - .user - .check_valid_password(&data.data.MasterPasswordHash) - { - err!("Invalid password"); - } + data.validate(&user, false, &mut conn).await?; - let (enabled, registrations) = - get_webauthn_registrations(&headers.user.uuid, &mut conn).await?; + let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &mut conn).await?; let registrations_json: Vec<Value> = registrations .iter() .map(WebauthnRegistration::to_json) .collect(); - Ok(Json(json!({ "Enabled": enabled, "Keys": registrations_json, @@ -144,18 +139,14 @@ async fn get_webauthn( #[post("/two-factor/get-webauthn-challenge", data = "<data>")] async fn generate_webauthn_challenge( - data: JsonUpcase<PasswordData>, + data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn, ) -> JsonResult { - if !headers - .user - .check_valid_password(&data.data.MasterPasswordHash) - { - err!("Invalid password"); - } - - let registrations = get_webauthn_registrations(&headers.user.uuid, &mut conn) + let data: PasswordOrOtpData = data.into_inner().data; + let user = headers.user; + data.validate(&user, false, &mut conn).await?; + let registrations = get_webauthn_registrations(&user.uuid, &mut conn) .await? .1 .into_iter() @@ -163,19 +154,18 @@ async fn generate_webauthn_challenge( .collect(); let (challenge, state) = WebauthnConfig::load().generate_challenge_register_options( - headers.user.uuid.as_bytes().to_vec(), - headers.user.email, - headers.user.name, + user.uuid.as_bytes().to_vec(), + user.email, + user.name, Some(registrations), None, None, )?; let type_ = TwoFactorType::WebauthnRegisterChallenge; - TwoFactor::new(headers.user.uuid, type_, serde_json::to_string(&state)?) + TwoFactor::new(user.uuid, type_, serde_json::to_string(&state)?) .save(&mut conn) .await?; - let mut challenge_value = serde_json::to_value(challenge.public_key)?; challenge_value["status"] = "ok".into(); challenge_value["errorMessage"] = "".into(); @@ -187,8 +177,9 @@ async fn generate_webauthn_challenge( struct EnableWebauthnData { Id: NumberOrString, // 1..5 Name: String, - MasterPasswordHash: String, DeviceResponse: RegisterPublicKeyCredentialCopy, + MasterPasswordHash: Option<String>, + Otp: Option<String>, } // This is copied from RegisterPublicKeyCredential to change the Response objects casing @@ -278,9 +269,12 @@ async fn activate_webauthn( ) -> JsonResult { let data: EnableWebauthnData = data.into_inner().data; let user = headers.user; - if !user.check_valid_password(&data.MasterPasswordHash) { - err!("Invalid password"); + PasswordOrOtpData { + MasterPasswordHash: data.MasterPasswordHash, + Otp: data.Otp, } + .validate(&user, true, &mut conn) + .await?; // Retrieve and delete the saved challenge state let type_ = TwoFactorType::WebauthnRegisterChallenge as i32; diff --git a/src/api/mod.rs b/src/api/mod.rs @@ -25,6 +25,7 @@ pub use crate::api::{ web::routes as web_routes, web::static_files, }; +use crate::db::{models::User, DbConn}; use crate::util; // Type aliases for API methods results @@ -39,8 +40,31 @@ type JsonVec<T> = Json<Vec<T>>; // Common structs representing JSON data received #[derive(Deserialize)] #[allow(non_snake_case)] -struct PasswordData { - MasterPasswordHash: String, +struct PasswordOrOtpData { + MasterPasswordHash: Option<String>, + Otp: Option<String>, +} + +impl PasswordOrOtpData { + /// Tokens used via this struct can be used multiple times during the process + /// First for the validation to continue, after that to enable or validate the following actions + /// This is different per caller, so it can be adjusted to delete the token or not + pub async fn validate(&self, user: &User, delete_if_valid: bool, conn: &mut DbConn) -> EmptyResult { + use crate::api::core::two_factor::protected_actions::validate_protected_action_otp; + + match (self.MasterPasswordHash.as_deref(), self.Otp.as_deref()) { + (Some(pw_hash), None) => { + if !user.check_valid_password(pw_hash) { + err!("Invalid password"); + } + } + (None, Some(otp)) => { + validate_protected_action_otp(otp, &user.uuid, delete_if_valid, conn).await?; + } + _ => err!("No validation provided"), + } + Ok(()) + } } #[derive(Deserialize, Debug, Clone)] diff --git a/src/config.rs b/src/config.rs @@ -822,17 +822,18 @@ where reg!("email/invite_accepted", ".html"); reg!("email/invite_confirmed", ".html"); reg!("email/new_device_logged_in", ".html"); + reg!("email/protected_action", ".html"); reg!("email/pw_hint_none", ".html"); reg!("email/pw_hint_some", ".html"); reg!("email/send_2fa_removed_from_org", ".html"); - reg!("email/send_single_org_removed_from_org", ".html"); - reg!("email/send_org_invite", ".html"); reg!("email/send_emergency_access_invite", ".html"); + reg!("email/send_org_invite", ".html"); + reg!("email/send_single_org_removed_from_org", ".html"); + reg!("email/smtp_test", ".html"); reg!("email/twofactor_email", ".html"); reg!("email/verify_email", ".html"); - reg!("email/welcome", ".html"); reg!("email/welcome_must_verify", ".html"); - reg!("email/smtp_test", ".html"); + reg!("email/welcome", ".html"); reg!("admin/base"); reg!("admin/login"); diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs @@ -23,6 +23,9 @@ pub enum TwoFactorType { Webauthn = 7, WebauthnRegisterChallenge = 1003, WebauthnLoginChallenge = 1004, + + // Special type for Protected Actions verification via email + ProtectedActions = 2000, } /// Local methods diff --git a/src/mail.rs b/src/mail.rs @@ -474,6 +474,19 @@ pub async fn send_admin_reset_password( 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", + json!({ + "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), + "token": token, + }), + )?; + + 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 { diff --git a/src/static/templates/email/protected_action.hbs b/src/static/templates/email/protected_action.hbs @@ -0,0 +1,6 @@ +Your Vaultwarden Verification Code +<!----------------> +Your email verification code is: {{token}} + +Use this code to complete the protected action in Vaultwarden. +{{> email/email_footer_text }} diff --git a/src/static/templates/email/protected_action.html.hbs b/src/static/templates/email/protected_action.html.hbs @@ -0,0 +1,16 @@ +Your Vaultwarden 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 complete the protected action in Vaultwarden. + </td> + </tr> +</table> +{{> email/email_footer }}