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 }}