commit f863ffb89a0f2a0a682c67110af32c731b5b9fcb
parent 03c6ed2e07a187bc70c8aa348546f25594cd38bd
Author: Mathijs van Veluw <black.dex@gmail.com>
Date: Sun, 12 Nov 2023 22:15:44 +0100
Add Protected Actions Check (#4067)
Since the feature `Login with device` some actions done via the
web-vault need to be verified via an OTP instead of providing the MasterPassword.
This only happens if a user used the `Login with device` on a device
which uses either Biometrics login or PIN. These actions prevent the
athorizing device to send the MasterPasswordHash. When this happens, the
web-vault requests an OTP to be filled-in and this OTP is send to the
users email address which is the same as the email address to login.
The only way to bypass this is by logging in with the your password, in
those cases a password is requested instead of an OTP.
In case SMTP is not enabled, it will show an error message telling to
user to login using there password.
Fixes #4042
Diffstat:
16 files changed, 337 insertions(+), 124 deletions(-)
diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs
@@ -6,7 +6,7 @@ use serde_json::Value;
use crate::{
api::{
core::log_user_event, register_push_device, unregister_push_device, AnonymousNotify, EmptyResult, JsonResult,
- JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType,
+ JsonUpcase, Notify, NumberOrString, PasswordOrOtpData, UpdateType,
},
auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers},
crypto,
@@ -503,17 +503,15 @@ async fn post_rotatekey(data: JsonUpcase<KeyData>, headers: Headers, mut conn: D
#[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();
@@ -736,18 +734,16 @@ async fn post_delete_recover_token(data: JsonUpcase<DeleteRecoverTokenData>, mut
}
#[post("/accounts/delete", data = "<data>")]
-async fn post_delete_account(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> EmptyResult {
+async fn post_delete_account(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> EmptyResult {
delete_account(data, headers, conn).await
}
#[delete("/accounts", data = "<data>")]
-async fn delete_account(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: DbConn) -> EmptyResult {
- let data: PasswordData = data.into_inner().data;
+async fn delete_account(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> EmptyResult {
+ 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
}
@@ -854,20 +850,13 @@ fn verify_password(data: JsonUpcase<SecretVerificationRequest>, headers: Headers
Ok(())
}
-async fn _api_key(
- data: JsonUpcase<SecretVerificationRequest>,
- rotate: bool,
- headers: Headers,
- mut conn: DbConn,
-) -> JsonResult {
+async fn _api_key(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());
@@ -882,12 +871,12 @@ async fn _api_key(
}
#[post("/accounts/api-key", data = "<data>")]
-async fn api_key(data: JsonUpcase<SecretVerificationRequest>, headers: Headers, conn: DbConn) -> JsonResult {
+async fn api_key(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
_api_key(data, false, headers, conn).await
}
#[post("/accounts/rotate-api-key", data = "<data>")]
-async fn rotate_api_key(data: JsonUpcase<SecretVerificationRequest>, headers: Headers, conn: DbConn) -> JsonResult {
+async fn rotate_api_key(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
_api_key(data, true, headers, conn).await
}
diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs
@@ -10,7 +10,7 @@ use rocket::{
use serde_json::Value;
use crate::{
- api::{self, core::log_event, EmptyResult, JsonResult, JsonUpcase, Notify, PasswordData, UpdateType},
+ api::{self, core::log_event, EmptyResult, JsonResult, JsonUpcase, Notify, PasswordOrOtpData, UpdateType},
auth::Headers,
crypto,
db::{models::*, DbConn, DbPool},
@@ -1457,19 +1457,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
@@ -6,7 +6,8 @@ use serde_json::Value;
use crate::{
api::{
core::{log_event, CipherSyncData, CipherSyncType},
- EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, JsonVec, Notify, NumberOrString, PasswordData, UpdateType,
+ EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, JsonVec, Notify, NumberOrString, PasswordOrOtpData,
+ UpdateType,
},
auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OwnerHeaders},
db::{models::*, DbConn},
@@ -186,16 +187,13 @@ async fn create_organization(headers: Headers, data: JsonUpcase<OrgData>, mut co
#[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"),
@@ -206,7 +204,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 {
@@ -2945,18 +2943,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) => {
@@ -2983,14 +2979,14 @@ async fn _api_key(
}
#[post("/organizations/<org_id>/api-key", data = "<data>")]
-async fn api_key(org_id: &str, data: JsonUpcase<PasswordData>, headers: AdminHeaders, conn: DbConn) -> JsonResult {
+async fn api_key(org_id: &str, data: JsonUpcase<PasswordOrOtpData>, headers: AdminHeaders, conn: DbConn) -> JsonResult {
_api_key(org_id, data, false, headers, conn).await
}
#[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
@@ -5,7 +5,7 @@ use rocket::Route;
use crate::{
api::{
core::log_user_event, core::two_factor::_generate_recover_code, EmptyResult, JsonResult, JsonUpcase,
- NumberOrString, PasswordData,
+ NumberOrString, PasswordOrOtpData,
},
auth::{ClientIp, Headers},
crypto,
@@ -22,13 +22,11 @@ pub fn routes() -> Vec<Route> {
}
#[post("/two-factor/get-authenticator", data = "<data>")]
-async fn generate_authenticator(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: DbConn) -> JsonResult {
- let data: PasswordData = data.into_inner().data;
+async fn generate_authenticator(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, false, &mut conn).await?;
let type_ = TwoFactorType::Authenticator as i32;
let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await;
@@ -48,9 +46,10 @@ async fn generate_authenticator(data: JsonUpcase<PasswordData>, headers: Headers
#[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>")]
@@ -60,15 +59,17 @@ 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 mut 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/duo.rs b/src/api/core/two_factor/duo.rs
@@ -6,7 +6,7 @@ use rocket::Route;
use crate::{
api::{
core::log_user_event, core::two_factor::_generate_recover_code, ApiResult, EmptyResult, JsonResult, JsonUpcase,
- PasswordData,
+ PasswordOrOtpData,
},
auth::Headers,
crypto,
@@ -92,14 +92,13 @@ impl DuoStatus {
const DISABLED_MESSAGE_DEFAULT: &str = "<To use the global Duo keys, please leave these fields untouched>";
#[post("/two-factor/get-duo", data = "<data>")]
-async fn get_duo(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: DbConn) -> JsonResult {
- let data: PasswordData = data.into_inner().data;
+async fn get_duo(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
+ let data: PasswordOrOtpData = data.into_inner().data;
+ let user = headers.user;
- if !headers.user.check_valid_password(&data.MasterPasswordHash) {
- err!("Invalid password");
- }
+ data.validate(&user, false, &mut conn).await?;
- let data = get_user_duo_data(&headers.user.uuid, &mut conn).await;
+ let data = get_user_duo_data(&user.uuid, &mut conn).await;
let (enabled, data) = match data {
DuoStatus::Global(_) => (true, Some(DuoData::secret())),
@@ -129,10 +128,11 @@ async fn get_duo(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: DbC
#[derive(Deserialize)]
#[allow(non_snake_case, dead_code)]
struct EnableDuoData {
- MasterPasswordHash: String,
Host: String,
SecretKey: String,
IntegrationKey: String,
+ MasterPasswordHash: Option<String>,
+ Otp: Option<String>,
}
impl From<EnableDuoData> for DuoData {
@@ -159,9 +159,12 @@ async fn activate_duo(data: JsonUpcase<EnableDuoData>, headers: Headers, mut con
let data: EnableDuoData = data.into_inner().data;
let mut user = headers.user;
- if !user.check_valid_password(&data.MasterPasswordHash) {
- err!("Invalid password");
+ PasswordOrOtpData {
+ MasterPasswordHash: data.MasterPasswordHash.clone(),
+ Otp: data.Otp.clone(),
}
+ .validate(&user, true, &mut conn)
+ .await?;
let (data, data_str) = if check_duo_fields_custom(&data) {
let data_req: DuoData = data.into();
diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs
@@ -5,7 +5,7 @@ use rocket::Route;
use crate::{
api::{
core::{log_user_event, two_factor::_generate_recover_code},
- EmptyResult, JsonResult, JsonUpcase, PasswordData,
+ EmptyResult, JsonResult, JsonUpcase, PasswordOrOtpData,
},
auth::Headers,
crypto,
@@ -76,13 +76,11 @@ pub async fn send_token(user_uuid: &str, conn: &mut DbConn) -> EmptyResult {
/// When user clicks on Manage email 2FA show the user the related information
#[post("/two-factor/get-email", data = "<data>")]
-async fn get_email(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: DbConn) -> JsonResult {
- let data: PasswordData = data.into_inner().data;
+async fn get_email(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, false, &mut conn).await?;
let (enabled, mfa_email) =
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &mut conn).await {
@@ -105,7 +103,8 @@ async fn get_email(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: D
struct SendEmailData {
/// Email where 2FA codes will be sent to, can be different than user email account.
Email: String,
- MasterPasswordHash: String,
+ MasterPasswordHash: Option<String>,
+ Otp: Option<String>,
}
/// Send a verification email to the specified email address to check whether it exists/belongs to user.
@@ -114,9 +113,12 @@ async fn send_email(data: JsonUpcase<SendEmailData>, headers: Headers, mut conn:
let data: SendEmailData = 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, false, &mut conn)
+ .await?;
if !CONFIG._enable_email_2fa() {
err!("Email 2FA is disabled")
@@ -144,8 +146,9 @@ async fn send_email(data: JsonUpcase<SendEmailData>, headers: Headers, mut conn:
#[allow(non_snake_case)]
struct EmailData {
Email: String,
- MasterPasswordHash: String,
Token: String,
+ MasterPasswordHash: Option<String>,
+ Otp: Option<String>,
}
/// Verify email belongs to user and can be used for 2FA email codes.
@@ -154,9 +157,13 @@ async fn email(data: JsonUpcase<EmailData>, headers: Headers, mut conn: DbConn)
let data: EmailData = data.into_inner().data;
let mut user = headers.user;
- if !user.check_valid_password(&data.MasterPasswordHash) {
- err!("Invalid password");
+ // This is the last step in the verification process, delete the otp directly afterwards
+ PasswordOrOtpData {
+ MasterPasswordHash: data.MasterPasswordHash,
+ Otp: data.Otp,
}
+ .validate(&user, true, &mut conn)
+ .await?;
let type_ = TwoFactorType::EmailVerificationChallenge as i32;
let mut twofactor =
diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs
@@ -5,7 +5,7 @@ use rocket::Route;
use serde_json::Value;
use crate::{
- api::{core::log_user_event, JsonResult, JsonUpcase, NumberOrString, PasswordData},
+ api::{core::log_user_event, JsonResult, JsonUpcase, NumberOrString, PasswordOrOtpData},
auth::{ClientHeaders, Headers},
crypto,
db::{models::*, DbConn, DbPool},
@@ -15,6 +15,7 @@ use crate::{
pub mod authenticator;
pub mod duo;
pub mod email;
+pub mod protected_actions;
pub mod webauthn;
pub mod yubikey;
@@ -33,6 +34,7 @@ pub fn routes() -> Vec<Route> {
routes.append(&mut email::routes());
routes.append(&mut webauthn::routes());
routes.append(&mut yubikey::routes());
+ routes.append(&mut protected_actions::routes());
routes
}
@@ -50,13 +52,11 @@ 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,
@@ -123,19 +123,23 @@ async fn _generate_recover_code(user: &mut User, conn: &mut DbConn) {
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct DisableTwoFactorData {
- MasterPasswordHash: String,
+ MasterPasswordHash: Option<String>,
+ Otp: Option<String>,
Type: NumberOrString,
}
#[post("/two-factor/disable", data = "<data>")]
async fn disable_twofactor(data: JsonUpcase<DisableTwoFactorData>, headers: Headers, 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::{base64_data::Base64UrlSafeData, proto::*, AuthenticationState,
use crate::{
api::{
core::{log_user_event, two_factor::_generate_recover_code},
- EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData,
+ EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordOrOtpData,
},
auth::Headers,
db::{
@@ -103,16 +103,17 @@ impl WebauthnRegistration {
}
#[post("/two-factor/get-webauthn", data = "<data>")]
-async fn get_webauthn(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: DbConn) -> JsonResult {
+async fn get_webauthn(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
if !CONFIG.domain_set() {
err!("`DOMAIN` environment variable is not set. Webauthn disabled")
}
- if !headers.user.check_valid_password(&data.data.MasterPasswordHash) {
- err!("Invalid password");
- }
+ let data: PasswordOrOtpData = data.into_inner().data;
+ let user = headers.user;
- let (enabled, registrations) = get_webauthn_registrations(&headers.user.uuid, &mut conn).await?;
+ data.validate(&user, false, &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!({
@@ -123,12 +124,17 @@ async fn get_webauthn(data: JsonUpcase<PasswordData>, headers: Headers, mut conn
}
#[post("/two-factor/get-webauthn-challenge", data = "<data>")]
-async fn generate_webauthn_challenge(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: DbConn) -> JsonResult {
- if !headers.user.check_valid_password(&data.data.MasterPasswordHash) {
- err!("Invalid password");
- }
+async fn generate_webauthn_challenge(
+ data: JsonUpcase<PasswordOrOtpData>,
+ headers: Headers,
+ mut conn: DbConn,
+) -> JsonResult {
+ let data: PasswordOrOtpData = data.into_inner().data;
+ let user = headers.user;
+
+ data.validate(&user, false, &mut conn).await?;
- let registrations = get_webauthn_registrations(&headers.user.uuid, &mut conn)
+ let registrations = get_webauthn_registrations(&user.uuid, &mut conn)
.await?
.1
.into_iter()
@@ -136,16 +142,16 @@ async fn generate_webauthn_challenge(data: JsonUpcase<PasswordData>, headers: He
.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)?).save(&mut conn).await?;
+ 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();
@@ -158,8 +164,9 @@ async fn generate_webauthn_challenge(data: JsonUpcase<PasswordData>, headers: He
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
@@ -246,9 +253,12 @@ async fn activate_webauthn(data: JsonUpcase<EnableWebauthnData>, headers: Header
let data: EnableWebauthnData = data.into_inner().data;
let mut 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/core/two_factor/yubikey.rs b/src/api/core/two_factor/yubikey.rs
@@ -6,7 +6,7 @@ use yubico::{config::Config, verify};
use crate::{
api::{
core::{log_user_event, two_factor::_generate_recover_code},
- EmptyResult, JsonResult, JsonUpcase, PasswordData,
+ EmptyResult, JsonResult, JsonUpcase, PasswordOrOtpData,
},
auth::Headers,
db::{
@@ -24,13 +24,14 @@ pub fn routes() -> Vec<Route> {
#[derive(Deserialize, Debug)]
#[allow(non_snake_case)]
struct EnableYubikeyData {
- MasterPasswordHash: String,
Key1: Option<String>,
Key2: Option<String>,
Key3: Option<String>,
Key4: Option<String>,
Key5: Option<String>,
Nfc: bool,
+ MasterPasswordHash: Option<String>,
+ Otp: Option<String>,
}
#[derive(Deserialize, Serialize, Debug)]
@@ -83,16 +84,14 @@ async fn verify_yubikey_otp(otp: String) -> EmptyResult {
}
#[post("/two-factor/get-yubikey", data = "<data>")]
-async fn generate_yubikey(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: DbConn) -> JsonResult {
+async fn generate_yubikey(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
// Make sure the credentials are set
get_yubico_credentials()?;
- 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 user_uuid = &user.uuid;
let yubikey_type = TwoFactorType::YubiKey as i32;
@@ -122,9 +121,12 @@ async fn activate_yubikey(data: JsonUpcase<EnableYubikeyData>, headers: Headers,
let data: EnableYubikeyData = data.into_inner().data;
let mut user = headers.user;
- if !user.check_valid_password(&data.MasterPasswordHash) {
- err!("Invalid password");
+ PasswordOrOtpData {
+ MasterPasswordHash: data.MasterPasswordHash.clone(),
+ Otp: data.Otp.clone(),
}
+ .validate(&user, true, &mut conn)
+ .await?;
// Check if we already have some data
let mut yubikey_data =
diff --git a/src/api/mod.rs b/src/api/mod.rs
@@ -32,6 +32,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
@@ -46,8 +47,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
@@ -1243,17 +1243,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
@@ -34,6 +34,9 @@ pub enum TwoFactorType {
EmailVerificationChallenge = 1002,
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
@@ -517,6 +517,19 @@ pub async fn send_admin_reset_password(address: &str, user_name: &str, org_name:
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 }}