commit 5d7715e96361acfed8a4eefbf6434796d1758892
parent 891178d5d5fd4421e4214d182f00a7fa5fd39f00
Author: Zack Newman <zack@philomathiclife.com>
Date: Mon, 13 Nov 2023 09:38:33 -0700
protected actions merge
Diffstat:
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 }}