From f863ffb89a0f2a0a682c67110af32c731b5b9fcb Mon Sep 17 00:00:00 2001 From: Mathijs van Veluw Date: Sun, 12 Nov 2023 22:15:44 +0100 Subject: [PATCH] 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 --- src/api/core/accounts.rs | 37 ++--- src/api/core/ciphers.rs | 12 +- src/api/core/organizations.rs | 30 ++-- src/api/core/two_factor/authenticator.rs | 21 +-- src/api/core/two_factor/duo.rs | 23 +-- src/api/core/two_factor/email.rs | 31 ++-- src/api/core/two_factor/mod.rs | 24 +-- src/api/core/two_factor/protected_actions.rs | 142 ++++++++++++++++++ src/api/core/two_factor/webauthn.rs | 46 +++--- src/api/core/two_factor/yubikey.rs | 20 +-- src/api/mod.rs | 28 +++- src/config.rs | 9 +- src/db/models/two_factor.rs | 3 + src/mail.rs | 13 ++ .../templates/email/protected_action.hbs | 6 + .../templates/email/protected_action.html.hbs | 16 ++ 16 files changed, 337 insertions(+), 124 deletions(-) create mode 100644 src/api/core/two_factor/protected_actions.rs create mode 100644 src/static/templates/email/protected_action.hbs create mode 100644 src/static/templates/email/protected_action.html.hbs diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 6f6e2f3d..4e30cbe5 100644 --- 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, headers: Headers, mut conn: D #[post("/accounts/security-stamp", data = "")] async fn post_sstamp( - data: JsonUpcase, + data: JsonUpcase, 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, mut } #[post("/accounts/delete", data = "")] -async fn post_delete_account(data: JsonUpcase, headers: Headers, conn: DbConn) -> EmptyResult { +async fn post_delete_account(data: JsonUpcase, headers: Headers, conn: DbConn) -> EmptyResult { delete_account(data, headers, conn).await } #[delete("/accounts", data = "")] -async fn delete_account(data: JsonUpcase, headers: Headers, mut conn: DbConn) -> EmptyResult { - let data: PasswordData = data.into_inner().data; +async fn delete_account(data: JsonUpcase, 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, headers: Headers Ok(()) } -async fn _api_key( - data: JsonUpcase, - rotate: bool, - headers: Headers, - mut conn: DbConn, -) -> JsonResult { +async fn _api_key(data: JsonUpcase, 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 = "")] -async fn api_key(data: JsonUpcase, headers: Headers, conn: DbConn) -> JsonResult { +async fn api_key(data: JsonUpcase, headers: Headers, conn: DbConn) -> JsonResult { _api_key(data, false, headers, conn).await } #[post("/accounts/rotate-api-key", data = "")] -async fn rotate_api_key(data: JsonUpcase, headers: Headers, conn: DbConn) -> JsonResult { +async fn rotate_api_key(data: JsonUpcase, 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 index dc3f4dc7..2489337e 100644 --- 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?", data = "")] async fn delete_all( organization: Option, - data: JsonUpcase, + data: JsonUpcase, 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 index 163c42ef..59079e01 100644 --- 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, mut co #[delete("/organizations/", data = "")] async fn delete_organization( org_id: &str, - data: JsonUpcase, + data: JsonUpcase, 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//delete", data = "")] async fn post_delete_organization( org_id: &str, - data: JsonUpcase, + data: JsonUpcase, 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, + data: JsonUpcase, 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//api-key", data = "")] -async fn api_key(org_id: &str, data: JsonUpcase, headers: AdminHeaders, conn: DbConn) -> JsonResult { +async fn api_key(org_id: &str, data: JsonUpcase, headers: AdminHeaders, conn: DbConn) -> JsonResult { _api_key(org_id, data, false, headers, conn).await } #[post("/organizations//rotate-api-key", data = "")] async fn rotate_api_key( org_id: &str, - data: JsonUpcase, + data: JsonUpcase, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { diff --git a/src/api/core/two_factor/authenticator.rs b/src/api/core/two_factor/authenticator.rs index 18740454..dfb970f8 100644 --- 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 { } #[post("/two-factor/get-authenticator", data = "")] -async fn generate_authenticator(data: JsonUpcase, headers: Headers, mut conn: DbConn) -> JsonResult { - let data: PasswordData = data.into_inner().data; +async fn generate_authenticator(data: JsonUpcase, 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, headers: Headers #[derive(Deserialize, Debug)] #[allow(non_snake_case)] struct EnableAuthenticatorData { - MasterPasswordHash: String, Key: String, Token: NumberOrString, + MasterPasswordHash: Option, + Otp: Option, } #[post("/two-factor/authenticator", 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 = match BASE32.decode(key.as_bytes()) { diff --git a/src/api/core/two_factor/duo.rs b/src/api/core/two_factor/duo.rs index c4ca0ba8..ea5589fb 100644 --- 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 = ""; #[post("/two-factor/get-duo", data = "")] -async fn get_duo(data: JsonUpcase, headers: Headers, mut conn: DbConn) -> JsonResult { - let data: PasswordData = data.into_inner().data; +async fn get_duo(data: JsonUpcase, 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, 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, + Otp: Option, } impl From for DuoData { @@ -159,9 +159,12 @@ async fn activate_duo(data: JsonUpcase, 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 index 1ca5152b..e1ee847f 100644 --- 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 = "")] -async fn get_email(data: JsonUpcase, headers: Headers, mut conn: DbConn) -> JsonResult { - let data: PasswordData = data.into_inner().data; +async fn get_email(data: JsonUpcase, 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, 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, + Otp: Option, } /// 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, 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, headers: Headers, mut conn: #[allow(non_snake_case)] struct EmailData { Email: String, - MasterPasswordHash: String, Token: String, + MasterPasswordHash: Option, + Otp: Option, } /// Verify email belongs to user and can be used for 2FA email codes. @@ -154,9 +157,13 @@ async fn email(data: JsonUpcase, 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 index 35c1867f..41368666 100644 --- 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 { 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 { } #[post("/two-factor/get-recover", data = "")] -fn get_recover(data: JsonUpcase, headers: Headers) -> JsonResult { - let data: PasswordData = data.into_inner().data; +async fn get_recover(data: JsonUpcase, 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, + Otp: Option, Type: NumberOrString, } #[post("/two-factor/disable", data = "")] async fn disable_twofactor(data: JsonUpcase, 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 new file mode 100644 index 00000000..09c7ede0 --- /dev/null +++ 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 { + 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 { + let res: Result = 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 = "")] +async fn verify_otp(data: JsonUpcase, 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 index 3c62754a..e228ea8c 100644 --- 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 = "")] -async fn get_webauthn(data: JsonUpcase, headers: Headers, mut conn: DbConn) -> JsonResult { +async fn get_webauthn(data: JsonUpcase, 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 = registrations.iter().map(WebauthnRegistration::to_json).collect(); Ok(Json(json!({ @@ -123,12 +124,17 @@ async fn get_webauthn(data: JsonUpcase, headers: Headers, mut conn } #[post("/two-factor/get-webauthn-challenge", data = "")] -async fn generate_webauthn_challenge(data: JsonUpcase, 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, + headers: Headers, + mut conn: DbConn, +) -> JsonResult { + let data: PasswordOrOtpData = data.into_inner().data; + let user = headers.user; - let registrations = get_webauthn_registrations(&headers.user.uuid, &mut conn) + data.validate(&user, false, &mut conn).await?; + + let registrations = get_webauthn_registrations(&user.uuid, &mut conn) .await? .1 .into_iter() @@ -136,16 +142,16 @@ async fn generate_webauthn_challenge(data: JsonUpcase, 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, headers: He struct EnableWebauthnData { Id: NumberOrString, // 1..5 Name: String, - MasterPasswordHash: String, DeviceResponse: RegisterPublicKeyCredentialCopy, + MasterPasswordHash: Option, + Otp: Option, } // This is copied from RegisterPublicKeyCredential to change the Response objects casing @@ -246,9 +253,12 @@ async fn activate_webauthn(data: JsonUpcase, 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 index 7681ab01..ea43f36f 100644 --- 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 { #[derive(Deserialize, Debug)] #[allow(non_snake_case)] struct EnableYubikeyData { - MasterPasswordHash: String, Key1: Option, Key2: Option, Key3: Option, Key4: Option, Key5: Option, Nfc: bool, + MasterPasswordHash: Option, + Otp: Option, } #[derive(Deserialize, Serialize, Debug)] @@ -83,16 +84,14 @@ async fn verify_yubikey_otp(otp: String) -> EmptyResult { } #[post("/two-factor/get-yubikey", data = "")] -async fn generate_yubikey(data: JsonUpcase, headers: Headers, mut conn: DbConn) -> JsonResult { +async fn generate_yubikey(data: JsonUpcase, 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, 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 index fd181fda..bf9d0a0d 100644 --- 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 = Json>; // Common structs representing JSON data received #[derive(Deserialize)] #[allow(non_snake_case)] -struct PasswordData { - MasterPasswordHash: String, +struct PasswordOrOtpData { + MasterPasswordHash: Option, + Otp: Option, +} + +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 index 67ba66ae..041e89a7 100644 --- 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 index ef03979a..93fb3385 100644 --- 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 index b5f1ea87..151554a1 100644 --- 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 new file mode 100644 index 00000000..98564199 --- /dev/null +++ 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 new file mode 100644 index 00000000..894447bc --- /dev/null +++ b/src/static/templates/email/protected_action.html.hbs @@ -0,0 +1,16 @@ +Your Vaultwarden Verification Code + +{{> email/email_header }} + + + + + + + +
+ Your email verification code is: {{token}} +
+ Use this code to complete the protected action in Vaultwarden. +
+{{> email/email_footer }}