From 996b60e43d5db7149263a7c5a5b1c9e9725f1f00 Mon Sep 17 00:00:00 2001 From: BlackDex Date: Fri, 30 Dec 2022 21:23:55 +0100 Subject: [PATCH 1/5] Update WebSocket Notifications Previously the websocket notifications were using `app_id` as the `ContextId`. This was incorrect and should have been the device_uuid from the client device executing the request. The clients will ignore the websocket request if the uuid matches. This also fixes some issues with the Desktop client which is able to modify attachments within the same screen and causes an issue when saving the attachment afterwards. Also changed the way to handle removed attachments, since that causes an error saving the vault cipher afterwards, complaining about a missing attachment. Bitwarden ignores this, and continues with the remaining attachments (if any). This also fixes #2591 . Further some more websocket notifications have been added to some other functions which enhance the user experience. - Logout users when deauthed, changed password, rotated keys - Trigger OrgSyncKeys on user confirm and removal - Added some extra to the send feature Also renamed UpdateTypes to match Bitwarden naming. --- src/api/admin.rs | 18 ++++++--- src/api/core/accounts.rs | 47 ++++++++++++++++++---- src/api/core/ciphers.rs | 73 +++++++++++++++++++++++++++-------- src/api/core/folders.rs | 6 +-- src/api/core/mod.rs | 27 ++++++++----- src/api/core/organizations.rs | 30 +++++++++++--- src/api/core/sends.rs | 6 +++ src/api/notifications.rs | 43 ++++++++++++++------- 8 files changed, 187 insertions(+), 63 deletions(-) diff --git a/src/api/admin.rs b/src/api/admin.rs index 6c908bfc..b108b24a 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -13,7 +13,7 @@ use rocket::{ }; use crate::{ - api::{core::log_event, ApiResult, EmptyResult, JsonResult, NumberOrString}, + api::{core::log_event, ApiResult, EmptyResult, JsonResult, Notify, NumberOrString, UpdateType}, auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp}, config::ConfigBuilder, db::{backup_database, get_sql_server_version, models::*, DbConn, DbConnType}, @@ -380,22 +380,30 @@ async fn delete_user(uuid: String, _token: AdminToken, mut conn: DbConn, ip: Cli } #[post("/users//deauth")] -async fn deauth_user(uuid: String, _token: AdminToken, mut conn: DbConn) -> EmptyResult { +async fn deauth_user(uuid: String, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { let mut user = get_user_or_404(&uuid, &mut conn).await?; Device::delete_all_by_user(&user.uuid, &mut conn).await?; user.reset_security_stamp(); - user.save(&mut conn).await + let save_result = user.save(&mut conn).await; + + nt.send_user_update(UpdateType::LogOut, &user).await; + + save_result } #[post("/users//disable")] -async fn disable_user(uuid: String, _token: AdminToken, mut conn: DbConn) -> EmptyResult { +async fn disable_user(uuid: String, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { let mut user = get_user_or_404(&uuid, &mut conn).await?; Device::delete_all_by_user(&user.uuid, &mut conn).await?; user.reset_security_stamp(); user.enabled = false; - user.save(&mut conn).await + let save_result = user.save(&mut conn).await; + + nt.send_user_update(UpdateType::LogOut, &user).await; + + save_result } #[post("/users//enable")] diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 3315fbce..31094499 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -275,6 +275,7 @@ async fn post_password( headers: Headers, mut conn: DbConn, ip: ClientIp, + nt: Notify<'_>, ) -> EmptyResult { let data: ChangePassData = data.into_inner().data; let mut user = headers.user; @@ -293,7 +294,11 @@ async fn post_password( Some(vec![String::from("post_rotatekey"), String::from("get_contacts"), String::from("get_public_keys")]), ); user.akey = data.Key; - user.save(&mut conn).await + let save_result = user.save(&mut conn).await; + + nt.send_user_update(UpdateType::LogOut, &user).await; + + save_result } #[derive(Deserialize)] @@ -308,7 +313,7 @@ struct ChangeKdfData { } #[post("/accounts/kdf", data = "")] -async fn post_kdf(data: JsonUpcase, headers: Headers, mut conn: DbConn) -> EmptyResult { +async fn post_kdf(data: JsonUpcase, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { let data: ChangeKdfData = data.into_inner().data; let mut user = headers.user; @@ -320,7 +325,11 @@ async fn post_kdf(data: JsonUpcase, headers: Headers, mut conn: D user.client_kdf_type = data.Kdf; user.set_password(&data.NewMasterPasswordHash, None); user.akey = data.Key; - user.save(&mut conn).await + let save_result = user.save(&mut conn).await; + + nt.send_user_update(UpdateType::LogOut, &user).await; + + save_result } #[derive(Deserialize)] @@ -388,6 +397,7 @@ async fn post_rotatekey( // Prevent triggering cipher updates via WebSockets by settings UpdateType::None // The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues. + // We force the users to logout after the user has been saved to try and prevent these issues. update_cipher_from_data(&mut saved_cipher, cipher_data, &headers, false, &mut conn, &ip, &nt, UpdateType::None) .await? } @@ -399,11 +409,20 @@ async fn post_rotatekey( user.private_key = Some(data.PrivateKey); user.reset_security_stamp(); - user.save(&mut conn).await + let save_result = user.save(&mut conn).await; + + nt.send_user_update(UpdateType::LogOut, &user).await; + + save_result } #[post("/accounts/security-stamp", data = "")] -async fn post_sstamp(data: JsonUpcase, headers: Headers, mut conn: DbConn) -> EmptyResult { +async fn post_sstamp( + data: JsonUpcase, + headers: Headers, + mut conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { let data: PasswordData = data.into_inner().data; let mut user = headers.user; @@ -413,7 +432,11 @@ async fn post_sstamp(data: JsonUpcase, headers: Headers, mut conn: Device::delete_all_by_user(&user.uuid, &mut conn).await?; user.reset_security_stamp(); - user.save(&mut conn).await + let save_result = user.save(&mut conn).await; + + nt.send_user_update(UpdateType::LogOut, &user).await; + + save_result } #[derive(Deserialize)] @@ -465,7 +488,12 @@ struct ChangeEmailData { } #[post("/accounts/email", data = "")] -async fn post_email(data: JsonUpcase, headers: Headers, mut conn: DbConn) -> EmptyResult { +async fn post_email( + data: JsonUpcase, + headers: Headers, + mut conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { let data: ChangeEmailData = data.into_inner().data; let mut user = headers.user; @@ -507,8 +535,11 @@ async fn post_email(data: JsonUpcase, headers: Headers, mut con user.set_password(&data.NewMasterPasswordHash, None); user.akey = data.Key; + let save_result = user.save(&mut conn).await; - user.save(&mut conn).await + nt.send_user_update(UpdateType::LogOut, &user).await; + + save_result } #[post("/accounts/verify-email")] diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index c72419b0..463f5f0b 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -310,7 +310,8 @@ async fn post_ciphers( data.LastKnownRevisionDate = None; let mut cipher = Cipher::new(data.Type, data.Name.clone()); - update_cipher_from_data(&mut cipher, data, &headers, false, &mut conn, &ip, &nt, UpdateType::CipherCreate).await?; + update_cipher_from_data(&mut cipher, data, &headers, false, &mut conn, &ip, &nt, UpdateType::SyncCipherCreate) + .await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, &mut conn).await)) } @@ -415,7 +416,14 @@ pub async fn update_cipher_from_data( for (id, attachment) in attachments { let mut saved_att = match Attachment::find_by_id(&id, conn).await { Some(att) => att, - None => err!("Attachment doesn't exist"), + None => { + // Warn and continue here. + // A missing attachment means it was removed via an other client. + // Also the Desktop Client supports removing attachments and save an update afterwards. + // Bitwarden it self ignores these mismatches server side. + warn!("Attachment {id} doesn't exist"); + continue; + } }; if saved_att.cipher_uuid != cipher.uuid { @@ -482,8 +490,8 @@ pub async fn update_cipher_from_data( // Only log events for organizational ciphers if let Some(org_uuid) = &cipher.organization_uuid { let event_type = match (&ut, transfer_cipher) { - (UpdateType::CipherCreate, true) => EventType::CipherCreated, - (UpdateType::CipherUpdate, true) => EventType::CipherShared, + (UpdateType::SyncCipherCreate, true) => EventType::CipherCreated, + (UpdateType::SyncCipherUpdate, true) => EventType::CipherShared, (_, _) => EventType::CipherUpdated, }; @@ -499,7 +507,7 @@ pub async fn update_cipher_from_data( .await; } - nt.send_cipher_update(ut, cipher, &cipher.update_users_revision(conn).await).await; + nt.send_cipher_update(ut, cipher, &cipher.update_users_revision(conn).await, &headers.device.uuid).await; } Ok(()) @@ -562,7 +570,7 @@ async fn post_ciphers_import( let mut user = headers.user; user.update_revision(&mut conn).await?; - nt.send_user_update(UpdateType::Vault, &user).await; + nt.send_user_update(UpdateType::SyncVault, &user).await; Ok(()) } @@ -628,7 +636,8 @@ async fn put_cipher( err!("Cipher is not write accessible") } - update_cipher_from_data(&mut cipher, data, &headers, false, &mut conn, &ip, &nt, UpdateType::CipherUpdate).await?; + update_cipher_from_data(&mut cipher, data, &headers, false, &mut conn, &ip, &nt, UpdateType::SyncCipherUpdate) + .await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, &mut conn).await)) } @@ -850,9 +859,9 @@ async fn share_cipher_by_uuid( // When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate. let ut = if data.Cipher.LastKnownRevisionDate.is_some() { - UpdateType::CipherUpdate + UpdateType::SyncCipherUpdate } else { - UpdateType::CipherCreate + UpdateType::SyncCipherCreate }; update_cipher_from_data(&mut cipher, data.Cipher, headers, shared_to_collection, conn, ip, nt, ut).await?; @@ -1067,7 +1076,13 @@ async fn save_attachment( data.data.move_copy_to(file_path).await? } - nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(&mut conn).await).await; + nt.send_cipher_update( + UpdateType::SyncCipherUpdate, + &cipher, + &cipher.update_users_revision(&mut conn).await, + &headers.device.uuid, + ) + .await; if let Some(org_uuid) = &cipher.organization_uuid { log_event( @@ -1403,7 +1418,7 @@ async fn move_cipher_selected( // Move cipher cipher.move_to_folder(data.FolderId.clone(), &user_uuid, &mut conn).await?; - nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &[user_uuid.clone()]).await; + nt.send_cipher_update(UpdateType::SyncCipherUpdate, &cipher, &[user_uuid.clone()], &headers.device.uuid).await; } Ok(()) @@ -1451,7 +1466,7 @@ async fn delete_all( Some(user_org) => { if user_org.atype == UserOrgType::Owner { Cipher::delete_all_by_organization(&org_data.org_id, &mut conn).await?; - nt.send_user_update(UpdateType::Vault, &user).await; + nt.send_user_update(UpdateType::SyncVault, &user).await; log_event( EventType::OrganizationPurgedVault as i32, @@ -1484,7 +1499,7 @@ async fn delete_all( } user.update_revision(&mut conn).await?; - nt.send_user_update(UpdateType::Vault, &user).await; + nt.send_user_update(UpdateType::SyncVault, &user).await; Ok(()) } } @@ -1510,10 +1525,22 @@ async fn _delete_cipher_by_uuid( if soft_delete { cipher.deleted_at = Some(Utc::now().naive_utc()); cipher.save(conn).await?; - nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(conn).await).await; + nt.send_cipher_update( + UpdateType::SyncCipherUpdate, + &cipher, + &cipher.update_users_revision(conn).await, + &headers.device.uuid, + ) + .await; } else { cipher.delete(conn).await?; - nt.send_cipher_update(UpdateType::CipherDelete, &cipher, &cipher.update_users_revision(conn).await).await; + nt.send_cipher_update( + UpdateType::SyncCipherDelete, + &cipher, + &cipher.update_users_revision(conn).await, + &headers.device.uuid, + ) + .await; } if let Some(org_uuid) = cipher.organization_uuid { @@ -1575,7 +1602,13 @@ async fn _restore_cipher_by_uuid( cipher.deleted_at = None; cipher.save(conn).await?; - nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(conn).await).await; + nt.send_cipher_update( + UpdateType::SyncCipherUpdate, + &cipher, + &cipher.update_users_revision(conn).await, + &headers.device.uuid, + ) + .await; if let Some(org_uuid) = &cipher.organization_uuid { log_event( EventType::CipherRestored as i32, @@ -1652,7 +1685,13 @@ async fn _delete_cipher_attachment_by_id( // Delete attachment attachment.delete(conn).await?; - nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(conn).await).await; + nt.send_cipher_update( + UpdateType::SyncCipherUpdate, + &cipher, + &cipher.update_users_revision(conn).await, + &headers.device.uuid, + ) + .await; if let Some(org_uuid) = cipher.organization_uuid { log_event( EventType::CipherAttachmentDeleted as i32, diff --git a/src/api/core/folders.rs b/src/api/core/folders.rs index 95155803..c27d5455 100644 --- a/src/api/core/folders.rs +++ b/src/api/core/folders.rs @@ -50,7 +50,7 @@ async fn post_folders(data: JsonUpcase, headers: Headers, mut conn: let mut folder = Folder::new(headers.user.uuid, data.Name); folder.save(&mut conn).await?; - nt.send_folder_update(UpdateType::FolderCreate, &folder).await; + nt.send_folder_update(UpdateType::SyncFolderCreate, &folder, &headers.device.uuid).await; Ok(Json(folder.to_json())) } @@ -88,7 +88,7 @@ async fn put_folder( folder.name = data.Name; folder.save(&mut conn).await?; - nt.send_folder_update(UpdateType::FolderUpdate, &folder).await; + nt.send_folder_update(UpdateType::SyncFolderUpdate, &folder, &headers.device.uuid).await; Ok(Json(folder.to_json())) } @@ -112,6 +112,6 @@ async fn delete_folder(uuid: String, headers: Headers, mut conn: DbConn, nt: Not // Delete the actual folder entry folder.delete(&mut conn).await?; - nt.send_folder_update(UpdateType::FolderDelete, &folder).await; + nt.send_folder_update(UpdateType::SyncFolderDelete, &folder, &headers.device.uuid).await; Ok(()) } diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 885fae81..3393a4d0 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -7,8 +7,7 @@ mod organizations; mod sends; pub mod two_factor; -pub use ciphers::purge_trashed_ciphers; -pub use ciphers::{CipherSyncData, CipherSyncType}; +pub use ciphers::{purge_trashed_ciphers, CipherSyncData, CipherSyncType}; pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; pub use events::{event_cleanup_job, log_event, log_user_event}; pub use sends::purge_sends; @@ -47,13 +46,11 @@ pub fn events_routes() -> Vec { // // Move this somewhere else // -use rocket::serde::json::Json; -use rocket::Catcher; -use rocket::Route; +use rocket::{serde::json::Json, Catcher, Route}; use serde_json::Value; use crate::{ - api::{JsonResult, JsonUpcase}, + api::{JsonResult, JsonUpcase, Notify, UpdateType}, auth::Headers, db::DbConn, error::Error, @@ -138,7 +135,12 @@ struct EquivDomainData { } #[post("/settings/domains", data = "")] -async fn post_eq_domains(data: JsonUpcase, headers: Headers, mut conn: DbConn) -> JsonResult { +async fn post_eq_domains( + data: JsonUpcase, + headers: Headers, + mut conn: DbConn, + nt: Notify<'_>, +) -> JsonResult { let data: EquivDomainData = data.into_inner().data; let excluded_globals = data.ExcludedGlobalEquivalentDomains.unwrap_or_default(); @@ -152,12 +154,19 @@ async fn post_eq_domains(data: JsonUpcase, headers: Headers, mu user.save(&mut conn).await?; + nt.send_user_update(UpdateType::SyncSettings, &user).await; + Ok(Json(json!({}))) } #[put("/settings/domains", data = "")] -async fn put_eq_domains(data: JsonUpcase, headers: Headers, conn: DbConn) -> JsonResult { - post_eq_domains(data, headers, conn).await +async fn put_eq_domains( + data: JsonUpcase, + headers: Headers, + conn: DbConn, + nt: Notify<'_>, +) -> JsonResult { + post_eq_domains(data, headers, conn, nt).await } #[get("/hibp/breach?")] diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index d50a1f12..60d6f714 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -957,6 +957,7 @@ async fn bulk_confirm_invite( headers: AdminHeaders, mut conn: DbConn, ip: ClientIp, + nt: Notify<'_>, ) -> Json { let data = data.into_inner().data; @@ -966,7 +967,8 @@ async fn bulk_confirm_invite( for invite in keys { let org_user_id = invite["Id"].as_str().unwrap_or_default(); let user_key = invite["Key"].as_str().unwrap_or_default(); - let err_msg = match _confirm_invite(&org_id, org_user_id, user_key, &headers, &mut conn, &ip).await { + let err_msg = match _confirm_invite(&org_id, org_user_id, user_key, &headers, &mut conn, &ip, &nt).await + { Ok(_) => String::new(), Err(e) => format!("{:?}", e), }; @@ -998,10 +1000,11 @@ async fn confirm_invite( headers: AdminHeaders, mut conn: DbConn, ip: ClientIp, + nt: Notify<'_>, ) -> EmptyResult { let data = data.into_inner().data; let user_key = data["Key"].as_str().unwrap_or_default(); - _confirm_invite(&org_id, &org_user_id, user_key, &headers, &mut conn, &ip).await + _confirm_invite(&org_id, &org_user_id, user_key, &headers, &mut conn, &ip, &nt).await } async fn _confirm_invite( @@ -1011,6 +1014,7 @@ async fn _confirm_invite( headers: &AdminHeaders, conn: &mut DbConn, ip: &ClientIp, + nt: &Notify<'_>, ) -> EmptyResult { if key.is_empty() || org_user_id.is_empty() { err!("Key or UserId is not set, unable to process request"); @@ -1069,7 +1073,13 @@ async fn _confirm_invite( mail::send_invite_confirmed(&address, &org_name).await?; } - user_to_confirm.save(conn).await + let save_result = user_to_confirm.save(conn).await; + + if let Some(user) = User::find_by_uuid(&user_to_confirm.user_uuid, conn).await { + nt.send_user_update(UpdateType::SyncOrgKeys, &user).await; + } + + save_result } #[get("/organizations//users/")] @@ -1206,12 +1216,13 @@ async fn bulk_delete_user( headers: AdminHeaders, mut conn: DbConn, ip: ClientIp, + nt: Notify<'_>, ) -> Json { let data: OrgBulkIds = data.into_inner().data; let mut bulk_response = Vec::new(); for org_user_id in data.Ids { - let err_msg = match _delete_user(&org_id, &org_user_id, &headers, &mut conn, &ip).await { + let err_msg = match _delete_user(&org_id, &org_user_id, &headers, &mut conn, &ip, &nt).await { Ok(_) => String::new(), Err(e) => format!("{:?}", e), }; @@ -1239,8 +1250,9 @@ async fn delete_user( headers: AdminHeaders, mut conn: DbConn, ip: ClientIp, + nt: Notify<'_>, ) -> EmptyResult { - _delete_user(&org_id, &org_user_id, &headers, &mut conn, &ip).await + _delete_user(&org_id, &org_user_id, &headers, &mut conn, &ip, &nt).await } #[post("/organizations//users//delete")] @@ -1250,8 +1262,9 @@ async fn post_delete_user( headers: AdminHeaders, mut conn: DbConn, ip: ClientIp, + nt: Notify<'_>, ) -> EmptyResult { - _delete_user(&org_id, &org_user_id, &headers, &mut conn, &ip).await + _delete_user(&org_id, &org_user_id, &headers, &mut conn, &ip, &nt).await } async fn _delete_user( @@ -1260,6 +1273,7 @@ async fn _delete_user( headers: &AdminHeaders, conn: &mut DbConn, ip: &ClientIp, + nt: &Notify<'_>, ) -> EmptyResult { let user_to_delete = match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, conn).await { Some(user) => user, @@ -1288,6 +1302,10 @@ async fn _delete_user( ) .await; + if let Some(user) = User::find_by_uuid(&user_to_delete.user_uuid, conn).await { + nt.send_user_update(UpdateType::SyncOrgKeys, &user).await; + } + user_to_delete.delete(conn).await } diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index e2107256..b7b2a0ce 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -381,6 +381,7 @@ async fn post_access( data: JsonUpcase, mut conn: DbConn, ip: ClientIp, + nt: Notify<'_>, ) -> JsonResult { let mut send = match Send::find_by_access_id(&access_id, &mut conn).await { Some(s) => s, @@ -422,6 +423,8 @@ async fn post_access( send.save(&mut conn).await?; + nt.send_send_update(UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&mut conn).await).await; + Ok(Json(send.to_json_access(&mut conn).await)) } @@ -432,6 +435,7 @@ async fn post_access_file( data: JsonUpcase, host: Host, mut conn: DbConn, + nt: Notify<'_>, ) -> JsonResult { let mut send = match Send::find_by_uuid(&send_id, &mut conn).await { Some(s) => s, @@ -470,6 +474,8 @@ async fn post_access_file( send.save(&mut conn).await?; + nt.send_send_update(UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&mut conn).await).await; + let token_claims = crate::auth::generate_send_claims(&send_id, &file_id); let token = crate::auth::encode_jwt(&token_claims); Ok(Json(json!({ diff --git a/src/api/notifications.rs b/src/api/notifications.rs index cd53c96d..b51e1380 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -164,12 +164,13 @@ impl WebSocketUsers { let data = create_update( vec![("UserId".into(), user.uuid.clone().into()), ("Date".into(), serialize_date(user.updated_at))], ut, + None, ); self.send_update(&user.uuid, &data).await; } - pub async fn send_folder_update(&self, ut: UpdateType, folder: &Folder) { + pub async fn send_folder_update(&self, ut: UpdateType, folder: &Folder, acting_device_uuid: &String) { let data = create_update( vec![ ("Id".into(), folder.uuid.clone().into()), @@ -177,12 +178,19 @@ impl WebSocketUsers { ("RevisionDate".into(), serialize_date(folder.updated_at)), ], ut, + Some(acting_device_uuid.into()), ); self.send_update(&folder.user_uuid, &data).await; } - pub async fn send_cipher_update(&self, ut: UpdateType, cipher: &Cipher, user_uuids: &[String]) { + pub async fn send_cipher_update( + &self, + ut: UpdateType, + cipher: &Cipher, + user_uuids: &[String], + acting_device_uuid: &String, + ) { let user_uuid = convert_option(cipher.user_uuid.clone()); let org_uuid = convert_option(cipher.organization_uuid.clone()); @@ -195,6 +203,7 @@ impl WebSocketUsers { ("RevisionDate".into(), serialize_date(cipher.updated_at)), ], ut, + Some(acting_device_uuid.into()), ); for uuid in user_uuids { @@ -212,6 +221,7 @@ impl WebSocketUsers { ("RevisionDate".into(), serialize_date(send.revision_date)), ], ut, + None, ); for uuid in user_uuids { @@ -228,14 +238,14 @@ impl WebSocketUsers { "ReceiveMessage", // Target [ // Arguments { - "ContextId": "app_id", + "ContextId": acting_device_uuid || Nil, "Type": ut as i32, "Payload": {} } ] ] */ -fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType) -> Vec { +fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_uuid: Option) -> Vec { use rmpv::Value as V; let value = V::Array(vec![ @@ -244,7 +254,7 @@ fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType) -> Vec { V::Nil, "ReceiveMessage".into(), V::Array(vec![V::Map(vec![ - ("ContextId".into(), "app_id".into()), + ("ContextId".into(), acting_device_uuid.map(|v| v.into()).unwrap_or_else(|| V::Nil)), ("Type".into(), (ut as i32).into()), ("Payload".into(), payload.into()), ])]), @@ -260,17 +270,17 @@ fn create_ping() -> Vec { #[allow(dead_code)] #[derive(Eq, PartialEq)] pub enum UpdateType { - CipherUpdate = 0, - CipherCreate = 1, - LoginDelete = 2, - FolderDelete = 3, - Ciphers = 4, + SyncCipherUpdate = 0, + SyncCipherCreate = 1, + SyncLoginDelete = 2, + SyncFolderDelete = 3, + SyncCiphers = 4, - Vault = 5, - OrgKeys = 6, - FolderCreate = 7, - FolderUpdate = 8, - CipherDelete = 9, + SyncVault = 5, + SyncOrgKeys = 6, + SyncFolderCreate = 7, + SyncFolderUpdate = 8, + SyncCipherDelete = 9, SyncSettings = 10, LogOut = 11, @@ -279,6 +289,9 @@ pub enum UpdateType { SyncSendUpdate = 13, SyncSendDelete = 14, + AuthRequest = 15, + AuthRequestResponse = 16, + None = 100, } From 17141147a8279bc6c79453a3ddd3435d41eb0336 Mon Sep 17 00:00:00 2001 From: Alex Martel <13215031+manofthepeace@users.noreply.github.com> Date: Fri, 9 Dec 2022 12:06:00 -0500 Subject: [PATCH 2/5] Remove patched multer-rs --- Cargo.lock | 3 ++- Cargo.toml | 6 ------ src/api/core/ciphers.rs | 13 ------------- src/api/core/sends.rs | 26 -------------------------- 4 files changed, 2 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8bf1ee42..c2f2e281 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1516,7 +1516,8 @@ dependencies = [ [[package]] name = "multer" version = "2.0.4" -source = "git+https://github.com/BlackDex/multer-rs?rev=477d16b7fa0f361b5c2a5ba18a5b28bec6d26a8a#477d16b7fa0f361b5c2a5ba18a5b28bec6d26a8a" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed4198ce7a4cbd2a57af78d28c6fbb57d81ac5f1d6ad79ac6c5587419cbdf22" dependencies = [ "bytes", "encoding_rs", diff --git a/Cargo.toml b/Cargo.toml index 4f4e4f0f..54feb671 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -152,12 +152,6 @@ semver = "1.0.14" # Mainly used for the musl builds, since the default musl malloc is very slow mimalloc = { version = "0.1.32", features = ["secure"], default-features = false, optional = true } -[patch.crates-io] -# Using a patched version of multer-rs (Used by Rocket) to fix attachment/send file uploads -# Issue: https://github.com/dani-garcia/vaultwarden/issues/2644 -# Patch: https://github.com/BlackDex/multer-rs/commit/477d16b7fa0f361b5c2a5ba18a5b28bec6d26a8a -multer = { git = "https://github.com/BlackDex/multer-rs", rev = "477d16b7fa0f361b5c2a5ba18a5b28bec6d26a8a" } - # Strip debuginfo from the release builds # Also enable thin LTO for some optimizations [profile.release] diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 463f5f0b..9750e4b6 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -1008,19 +1008,6 @@ async fn save_attachment( let mut data = data.into_inner(); - // There is a bug regarding uploading attachments/sends using the Mobile clients - // See: https://github.com/dani-garcia/vaultwarden/issues/2644 && https://github.com/bitwarden/mobile/issues/2018 - // This has been fixed via a PR: https://github.com/bitwarden/mobile/pull/2031, but hasn't landed in a new release yet. - // On the vaultwarden side this is temporarily fixed by using a custom multer library - // See: https://github.com/dani-garcia/vaultwarden/pull/2675 - // In any case we will match TempFile::File and not TempFile::Buffered, since Buffered will alter the contents. - if let TempFile::Buffered { - content: _, - } = &data.data - { - err!("Error reading attachment data. Please try an other client."); - } - if let Some(size_limit) = size_limit { if data.data.len() > size_limit { err!("Attachment storage limit exceeded with this file"); diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index b7b2a0ce..7d021d08 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -228,19 +228,6 @@ async fn post_send_file(data: Form>, headers: Headers, mut conn: err!("Send content is not a file"); } - // There is a bug regarding uploading attachments/sends using the Mobile clients - // See: https://github.com/dani-garcia/vaultwarden/issues/2644 && https://github.com/bitwarden/mobile/issues/2018 - // This has been fixed via a PR: https://github.com/bitwarden/mobile/pull/2031, but hasn't landed in a new release yet. - // On the vaultwarden side this is temporarily fixed by using a custom multer library - // See: https://github.com/dani-garcia/vaultwarden/pull/2675 - // In any case we will match TempFile::File and not TempFile::Buffered, since Buffered will alter the contents. - if let TempFile::Buffered { - content: _, - } = &data - { - err!("Error reading send file data. Please try an other client."); - } - let size = data.len(); if size > size_limit { err!("Attachment storage limit exceeded with this file"); @@ -339,19 +326,6 @@ async fn post_send_file_v2_data( let mut data = data.into_inner(); - // There is a bug regarding uploading attachments/sends using the Mobile clients - // See: https://github.com/dani-garcia/vaultwarden/issues/2644 && https://github.com/bitwarden/mobile/issues/2018 - // This has been fixed via a PR: https://github.com/bitwarden/mobile/pull/2031, but hasn't landed in a new release yet. - // On the vaultwarden side this is temporarily fixed by using a custom multer library - // See: https://github.com/dani-garcia/vaultwarden/pull/2675 - // In any case we will match TempFile::File and not TempFile::Buffered, since Buffered will alter the contents. - if let TempFile::Buffered { - content: _, - } = &data.data - { - err!("Error reading attachment data. Please try an other client."); - } - if let Some(send) = Send::find_by_uuid(&send_uuid, &mut conn).await { let folder_path = tokio::fs::canonicalize(&CONFIG.sends_folder()).await?.join(&send_uuid); let file_path = folder_path.join(&file_id); From 0c5b4476adaec2613f0df2af1350ef7ee7ba3cb2 Mon Sep 17 00:00:00 2001 From: BlackDex Date: Wed, 28 Dec 2022 20:05:10 +0100 Subject: [PATCH 3/5] Removed unsafe-inline JS from CSP and other fixes - Removed `unsafe-inline` for javascript from CSP. The admin interface now uses files instead of inline javascript. - Modified javascript to work not being inline. - Run eslint over javascript and fixed some items. - Added a `to_json` Handlebars helper. Used at the diagnostics page. - Changed `AdminTemplateData` struct to be smaller. The `config` was always added, but only used at one page. Same goes for `can_backup` and `version`. - Also inlined CSS. We can't remove the `unsafe-inline` from css, because that seems to break the web-vault currently. That might need some further checks. But for now the 404 page and all the admin pages are clear of inline scripts and styles. --- src/api/admin.rs | 40 ++- src/api/web.rs | 11 + src/config.rs | 15 ++ src/static/scripts/404.css | 26 ++ src/static/scripts/admin.css | 45 ++++ src/static/scripts/admin.js | 65 +++++ src/static/scripts/admin_diagnostics.js | 219 +++++++++++++++++ src/static/scripts/admin_organizations.js | 54 ++++ src/static/scripts/admin_settings.js | 180 ++++++++++++++ src/static/scripts/admin_users.js | 246 +++++++++++++++++++ src/static/scripts/bootstrap.css | 2 - src/static/templates/404.hbs | 28 +-- src/static/templates/admin/base.hbs | 96 +------- src/static/templates/admin/diagnostics.hbs | 207 +--------------- src/static/templates/admin/organizations.hbs | 45 +--- src/static/templates/admin/settings.hbs | 165 +------------ src/static/templates/admin/users.hbs | 202 +++------------ src/util.rs | 18 +- 18 files changed, 946 insertions(+), 718 deletions(-) create mode 100644 src/static/scripts/404.css create mode 100644 src/static/scripts/admin.css create mode 100644 src/static/scripts/admin.js create mode 100644 src/static/scripts/admin_diagnostics.js create mode 100644 src/static/scripts/admin_organizations.js create mode 100644 src/static/scripts/admin_settings.js create mode 100644 src/static/scripts/admin_users.js diff --git a/src/api/admin.rs b/src/api/admin.rs index b108b24a..6e0c2acf 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -144,7 +144,6 @@ fn render_admin_login(msg: Option<&str>, redirect: Option) -> ApiResult< let msg = msg.map(|msg| format!("Error: {msg}")); let json = json!({ "page_content": "admin/login", - "version": VERSION, "error": msg, "redirect": redirect, "urlpath": CONFIG.domain_path() @@ -208,34 +207,16 @@ fn _validate_token(token: &str) -> bool { #[derive(Serialize)] struct AdminTemplateData { page_content: String, - version: Option<&'static str>, page_data: Option, - config: Value, - can_backup: bool, logged_in: bool, urlpath: String, } impl AdminTemplateData { - fn new() -> Self { - Self { - page_content: String::from("admin/settings"), - version: VERSION, - config: CONFIG.prepare_json(), - can_backup: *CAN_BACKUP, - logged_in: true, - urlpath: CONFIG.domain_path(), - page_data: None, - } - } - - fn with_data(page_content: &str, page_data: Value) -> Self { + fn new(page_content: &str, page_data: Value) -> Self { Self { page_content: String::from(page_content), - version: VERSION, page_data: Some(page_data), - config: CONFIG.prepare_json(), - can_backup: *CAN_BACKUP, logged_in: true, urlpath: CONFIG.domain_path(), } @@ -247,7 +228,11 @@ impl AdminTemplateData { } fn render_admin_page() -> ApiResult> { - let text = AdminTemplateData::new().render()?; + let settings_json = json!({ + "config": CONFIG.prepare_json(), + "can_backup": *CAN_BACKUP, + }); + let text = AdminTemplateData::new("admin/settings", settings_json).render()?; Ok(Html(text)) } @@ -342,7 +327,7 @@ async fn users_overview(_token: AdminToken, mut conn: DbConn) -> ApiResult ApiResu organizations_json.push(org); } - let text = AdminTemplateData::with_data("admin/organizations", json!(organizations_json)).render()?; + let text = AdminTemplateData::new("admin/organizations", json!(organizations_json)).render()?; Ok(Html(text)) } @@ -625,13 +610,14 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn) let diagnostics_json = json!({ "dns_resolved": dns_resolved, + "current_release": VERSION, "latest_release": latest_release, "latest_commit": latest_commit, "web_vault_enabled": &CONFIG.web_vault_enabled(), "web_vault_version": web_vault_version.version, "latest_web_build": latest_web_build, "running_within_docker": running_within_docker, - "docker_base_image": docker_base_image(), + "docker_base_image": if running_within_docker { docker_base_image() } else { "Not applicable" }, "has_http_access": has_http_access, "ip_header_exists": &ip_header.0.is_some(), "ip_header_match": ip_header_name == CONFIG.ip_header(), @@ -642,11 +628,13 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn) "db_version": get_sql_server_version(&mut conn).await, "admin_url": format!("{}/diagnostics", admin_url()), "overrides": &CONFIG.get_overrides().join(", "), + "host_arch": std::env::consts::ARCH, + "host_os": std::env::consts::OS, "server_time_local": Local::now().format("%Y-%m-%d %H:%M:%S %Z").to_string(), "server_time": Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), // Run the date/time check as the last item to minimize the difference }); - let text = AdminTemplateData::with_data("admin/diagnostics", diagnostics_json).render()?; + let text = AdminTemplateData::new("admin/diagnostics", diagnostics_json).render()?; Ok(Html(text)) } diff --git a/src/api/web.rs b/src/api/web.rs index 3742a088..b8d1bb51 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -102,6 +102,17 @@ pub fn static_files(filename: String) -> Result<(ContentType, &'static [u8]), Er "hibp.png" => Ok((ContentType::PNG, include_bytes!("../static/images/hibp.png"))), "vaultwarden-icon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-icon.png"))), "vaultwarden-favicon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-favicon.png"))), + "404.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/404.css"))), + "admin.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/admin.css"))), + "admin.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin.js"))), + "admin_settings.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_settings.js"))), + "admin_users.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_users.js"))), + "admin_organizations.js" => { + Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_organizations.js"))) + } + "admin_diagnostics.js" => { + Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_diagnostics.js"))) + } "bootstrap.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/bootstrap.css"))), "bootstrap-native.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/bootstrap-native.js"))), "jdenticon.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon.js"))), diff --git a/src/config.rs b/src/config.rs index 00d4737c..2aa0f6bf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1086,6 +1086,7 @@ where // Register helpers hb.register_helper("case", Box::new(case_helper)); hb.register_helper("jsesc", Box::new(js_escape_helper)); + hb.register_helper("to_json", Box::new(to_json)); macro_rules! reg { ($name:expr) => {{ @@ -1183,3 +1184,17 @@ fn js_escape_helper<'reg, 'rc>( out.write(&escaped_value)?; Ok(()) } + +fn to_json<'reg, 'rc>( + h: &Helper<'reg, 'rc>, + _r: &'reg Handlebars<'_>, + _ctx: &'rc Context, + _rc: &mut RenderContext<'reg, 'rc>, + out: &mut dyn Output, +) -> HelperResult { + let param = h.param(0).ok_or_else(|| RenderError::new("Expected 1 parameter for \"to_json\""))?.value(); + let json = serde_json::to_string(param) + .map_err(|e| RenderError::new(format!("Can't serialize parameter to JSON: {}", e)))?; + out.write(&json)?; + Ok(()) +} diff --git a/src/static/scripts/404.css b/src/static/scripts/404.css new file mode 100644 index 00000000..c1024d2b --- /dev/null +++ b/src/static/scripts/404.css @@ -0,0 +1,26 @@ +body { + padding-top: 75px; +} +.vaultwarden-icon { + width: 48px; + height: 48px; + height: 32px; + width: auto; + margin: -5px 0 0 0; +} +.footer { + padding: 40px 0 40px 0; + border-top: 1px solid #dee2e6; +} +.container { + max-width: 980px; +} +.content { + padding-top: 20px; + padding-bottom: 20px; + padding-left: 15px; + padding-right: 15px; +} +.vw-404 { + max-width: 500px; width: 100%; +} \ No newline at end of file diff --git a/src/static/scripts/admin.css b/src/static/scripts/admin.css new file mode 100644 index 00000000..d77b5372 --- /dev/null +++ b/src/static/scripts/admin.css @@ -0,0 +1,45 @@ +body { + padding-top: 75px; +} +img { + width: 48px; + height: 48px; +} +.vaultwarden-icon { + height: 32px; + width: auto; + margin: -5px 0 0 0; +} +/* Special alert-row class to use Bootstrap v5.2+ variable colors */ +.alert-row { + --bs-alert-border: 1px solid var(--bs-alert-border-color); + color: var(--bs-alert-color); + background-color: var(--bs-alert-bg); + border: var(--bs-alert-border); +} + +#users-table .vw-created-at, #users-table .vw-last-active { + width: 85px; + min-width: 70px; +} +#users-table .vw-items { + width: 35px; + min-width: 35px; +} +#users-table .vw-organizations { + min-width: 120px; +} +#users-table .vw-actions, #orgs-table .vw-actions { + width: 130px; + min-width: 130px; +} +#users-table .vw-org-cell { + max-height: 120px; +} + +#support-string { + height: 16rem; +} +.vw-copy-toast { + width: 15rem; +} \ No newline at end of file diff --git a/src/static/scripts/admin.js b/src/static/scripts/admin.js new file mode 100644 index 00000000..7849ac19 --- /dev/null +++ b/src/static/scripts/admin.js @@ -0,0 +1,65 @@ +"use strict"; + +function getBaseUrl() { + // If the base URL is `https://vaultwarden.example.com/base/path/`, + // `window.location.href` should have one of the following forms: + // + // - `https://vaultwarden.example.com/base/path/` + // - `https://vaultwarden.example.com/base/path/#/some/route[?queryParam=...]` + // + // We want to get to just `https://vaultwarden.example.com/base/path`. + const baseUrl = window.location.href; + const adminPos = baseUrl.indexOf("/admin"); + return baseUrl.substring(0, adminPos != -1 ? adminPos : baseUrl.length); +} +const BASE_URL = getBaseUrl(); + +function reload() { + // Reload the page by setting the exact same href + // Using window.location.reload() could cause a repost. + window.location = window.location.href; +} + +function msg(text, reload_page = true) { + text && alert(text); + reload_page && reload(); +} + +function _post(url, successMsg, errMsg, body, reload_page = true) { + fetch(url, { + method: "POST", + body: body, + mode: "same-origin", + credentials: "same-origin", + headers: { "Content-Type": "application/json" } + }).then( resp => { + if (resp.ok) { msg(successMsg, reload_page); return Promise.reject({error: false}); } + const respStatus = resp.status; + const respStatusText = resp.statusText; + return resp.text(); + }).then( respText => { + try { + const respJson = JSON.parse(respText); + return respJson ? respJson.ErrorModel.Message : "Unknown error"; + } catch (e) { + return Promise.reject({body:respStatus + " - " + respStatusText, error: true}); + } + }).then( apiMsg => { + msg(errMsg + "\n" + apiMsg, reload_page); + }).catch( e => { + if (e.error === false) { return true; } + else { msg(errMsg + "\n" + e.body, reload_page); } + }); +} + +// onLoad events +document.addEventListener("DOMContentLoaded", (/*event*/) => { + // get current URL path and assign "active" class to the correct nav-item + const pathname = window.location.pathname; + if (pathname === "") return; + const navItem = document.querySelectorAll(`.navbar-nav .nav-item a[href="${pathname}"]`); + if (navItem.length === 1) { + navItem[0].className = navItem[0].className + " active"; + navItem[0].setAttribute("aria-current", "page"); + } +}); \ No newline at end of file diff --git a/src/static/scripts/admin_diagnostics.js b/src/static/scripts/admin_diagnostics.js new file mode 100644 index 00000000..84a7ecc5 --- /dev/null +++ b/src/static/scripts/admin_diagnostics.js @@ -0,0 +1,219 @@ +"use strict"; + +var dnsCheck = false; +var timeCheck = false; +var domainCheck = false; +var httpsCheck = false; + +// ================================ +// Date & Time Check +const d = new Date(); +const year = d.getUTCFullYear(); +const month = String(d.getUTCMonth()+1).padStart(2, "0"); +const day = String(d.getUTCDate()).padStart(2, "0"); +const hour = String(d.getUTCHours()).padStart(2, "0"); +const minute = String(d.getUTCMinutes()).padStart(2, "0"); +const seconds = String(d.getUTCSeconds()).padStart(2, "0"); +const browserUTC = `${year}-${month}-${day} ${hour}:${minute}:${seconds} UTC`; + +// ================================ +// Check if the output is a valid IP +const isValidIp = value => (/^(?:(?:^|\.)(?:2(?:5[0-5]|[0-4]\d)|1?\d?\d)){4}$/.test(value) ? true : false); + +function checkVersions(platform, installed, latest, commit=null) { + if (installed === "-" || latest === "-") { + document.getElementById(`${platform}-failed`).classList.remove("d-none"); + return; + } + + // Only check basic versions, no commit revisions + if (commit === null || installed.indexOf("-") === -1) { + if (installed !== latest) { + document.getElementById(`${platform}-warning`).classList.remove("d-none"); + } else { + document.getElementById(`${platform}-success`).classList.remove("d-none"); + } + } else { + // Check if this is a branched version. + const branchRegex = /(?:\s)\((.*?)\)/; + const branchMatch = installed.match(branchRegex); + if (branchMatch !== null) { + document.getElementById(`${platform}-branch`).classList.remove("d-none"); + } + + // This will remove branch info and check if there is a commit hash + const installedRegex = /(\d+\.\d+\.\d+)-(\w+)/; + const instMatch = installed.match(installedRegex); + + // It could be that a new tagged version has the same commit hash. + // In this case the version is the same but only the number is different + if (instMatch !== null) { + if (instMatch[2] === commit) { + // The commit hashes are the same, so latest version is installed + document.getElementById(`${platform}-success`).classList.remove("d-none"); + return; + } + } + + if (installed === latest) { + document.getElementById(`${platform}-success`).classList.remove("d-none"); + } else { + document.getElementById(`${platform}-warning`).classList.remove("d-none"); + } + } +} + +// ================================ +// Generate support string to be pasted on github or the forum +async function generateSupportString(dj) { + event.preventDefault(); + event.stopPropagation(); + + let supportString = "### Your environment (Generated via diagnostics page)\n"; + + supportString += `* Vaultwarden version: v${dj.current_release}\n`; + supportString += `* Web-vault version: v${dj.web_vault_version}\n`; + supportString += `* OS/Arch: ${dj.host_os}/${dj.host_arch}\n`; + supportString += `* Running within Docker: ${dj.running_within_docker} (Base: ${dj.docker_base_image})\n`; + supportString += "* Environment settings overridden: "; + if (dj.overrides != "") { + supportString += "true\n"; + } else { + supportString += "false\n"; + } + supportString += `* Uses a reverse proxy: ${dj.ip_header_exists}\n`; + if (dj.ip_header_exists) { + supportString += `* IP Header check: ${dj.ip_header_match} (${dj.ip_header_name})\n`; + } + supportString += `* Internet access: ${dj.has_http_access}\n`; + supportString += `* Internet access via a proxy: ${dj.uses_proxy}\n`; + supportString += `* DNS Check: ${dnsCheck}\n`; + supportString += `* Time Check: ${timeCheck}\n`; + supportString += `* Domain Configuration Check: ${domainCheck}\n`; + supportString += `* HTTPS Check: ${httpsCheck}\n`; + supportString += `* Database type: ${dj.db_type}\n`; + supportString += `* Database version: ${dj.db_version}\n`; + supportString += "* Clients used: \n"; + supportString += "* Reverse proxy and version: \n"; + supportString += "* Other relevant information: \n"; + + const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, { + "headers": { "Accept": "application/json" } + }); + if (!jsonResponse.ok) { + alert("Generation failed: " + jsonResponse.statusText); + throw new Error(jsonResponse); + } + const configJson = await jsonResponse.json(); + supportString += "\n### Config (Generated via diagnostics page)\n
Show Running Config\n"; + supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`; + supportString += "\n\n```json\n" + JSON.stringify(configJson, undefined, 2) + "\n```\n
\n"; + + document.getElementById("support-string").innerText = supportString; + document.getElementById("support-string").classList.remove("d-none"); + document.getElementById("copy-support").classList.remove("d-none"); +} + +function copyToClipboard() { + event.preventDefault(); + event.stopPropagation(); + + const supportStr = document.getElementById("support-string").innerText; + const tmpCopyEl = document.createElement("textarea"); + + tmpCopyEl.setAttribute("id", "copy-support-string"); + tmpCopyEl.setAttribute("readonly", ""); + tmpCopyEl.value = supportStr; + tmpCopyEl.style.position = "absolute"; + tmpCopyEl.style.left = "-9999px"; + document.body.appendChild(tmpCopyEl); + tmpCopyEl.select(); + document.execCommand("copy"); + tmpCopyEl.remove(); + + new BSN.Toast("#toastClipboardCopy").show(); +} + +function checkTimeDrift(browserUTC, serverUTC) { + const timeDrift = ( + Date.parse(serverUTC.replace(" ", "T").replace(" UTC", "")) - + Date.parse(browserUTC.replace(" ", "T").replace(" UTC", "")) + ) / 1000; + if (timeDrift > 20 || timeDrift < -20) { + document.getElementById("time-warning").classList.remove("d-none"); + } else { + document.getElementById("time-success").classList.remove("d-none"); + timeCheck = true; + } +} + +function checkDomain(browserURL, serverURL) { + if (serverURL == browserURL) { + document.getElementById("domain-success").classList.remove("d-none"); + domainCheck = true; + } else { + document.getElementById("domain-warning").classList.remove("d-none"); + } + + // Check for HTTPS at domain-server-string + if (serverURL.startsWith("https://") ) { + document.getElementById("https-success").classList.remove("d-none"); + httpsCheck = true; + } else { + document.getElementById("https-warning").classList.remove("d-none"); + } +} + +function initVersionCheck(dj) { + const serverInstalled = dj.current_release; + const serverLatest = dj.latest_release; + const serverLatestCommit = dj.latest_commit; + + if (serverInstalled.indexOf("-") !== -1 && serverLatest !== "-" && serverLatestCommit !== "-") { + document.getElementById("server-latest-commit").classList.remove("d-none"); + } + checkVersions("server", serverInstalled, serverLatest, serverLatestCommit); + + if (!dj.running_within_docker) { + const webInstalled = dj.web_vault_version; + const webLatest = dj.latest_web_build; + checkVersions("web", webInstalled, webLatest); + } +} + +function checkDns(dns_resolved) { + if (isValidIp(dns_resolved)) { + document.getElementById("dns-success").classList.remove("d-none"); + dnsCheck = true; + } else { + document.getElementById("dns-warning").classList.remove("d-none"); + } +} + +function init(dj) { + // Time check + document.getElementById("time-browser-string").innerText = browserUTC; + checkTimeDrift(browserUTC, dj.server_time); + + // Domain check + const browserURL = location.href.toLowerCase(); + document.getElementById("domain-browser-string").innerText = browserURL; + checkDomain(browserURL, dj.admin_url.toLowerCase()); + + // Version check + initVersionCheck(dj); + + // DNS Check + checkDns(dj.dns_resolved); +} + +// onLoad events +document.addEventListener("DOMContentLoaded", (/*event*/) => { + const diag_json = JSON.parse(document.getElementById("diagnostics_json").innerText); + init(diag_json); + + document.getElementById("gen-support").addEventListener("click", () => { + generateSupportString(diag_json); + }); + document.getElementById("copy-support").addEventListener("click", copyToClipboard); +}); \ No newline at end of file diff --git a/src/static/scripts/admin_organizations.js b/src/static/scripts/admin_organizations.js new file mode 100644 index 00000000..ae15e2fd --- /dev/null +++ b/src/static/scripts/admin_organizations.js @@ -0,0 +1,54 @@ +"use strict"; + +function deleteOrganization() { + event.preventDefault(); + event.stopPropagation(); + const org_uuid = event.target.dataset.vwOrgUuid; + const org_name = event.target.dataset.vwOrgName; + const billing_email = event.target.dataset.vwBillingEmail; + if (!org_uuid) { + alert("Required parameters not found!"); + return false; + } + + // First make sure the user wants to delete this organization + const continueDelete = confirm(`WARNING: All data of this organization (${org_name}) will be lost!\nMake sure you have a backup, this cannot be undone!`); + if (continueDelete == true) { + const input_org_uuid = prompt(`To delete the organization "${org_name} (${billing_email})", please type the organization uuid below.`); + if (input_org_uuid != null) { + if (input_org_uuid == org_uuid) { + _post(`${BASE_URL}/admin/organizations/${org_uuid}/delete`, + "Organization deleted correctly", + "Error deleting organization" + ); + } else { + alert("Wrong organization uuid, please try again"); + } + } + } +} + +// onLoad events +document.addEventListener("DOMContentLoaded", (/*event*/) => { + jQuery("#orgs-table").DataTable({ + "stateSave": true, + "responsive": true, + "lengthMenu": [ + [-1, 5, 10, 25, 50], + ["All", 5, 10, 25, 50] + ], + "pageLength": -1, // Default show all + "columnDefs": [{ + "targets": 4, + "searchable": false, + "orderable": false + }] + }); + + // Add click events for organization actions + document.querySelectorAll("button[vw-delete-organization]").forEach(btn => { + btn.addEventListener("click", deleteOrganization); + }); + + document.getElementById("reload").addEventListener("click", reload); +}); \ No newline at end of file diff --git a/src/static/scripts/admin_settings.js b/src/static/scripts/admin_settings.js new file mode 100644 index 00000000..4f248cbd --- /dev/null +++ b/src/static/scripts/admin_settings.js @@ -0,0 +1,180 @@ +"use strict"; + +function smtpTest() { + event.preventDefault(); + event.stopPropagation(); + if (formHasChanges(config_form)) { + alert("Config has been changed but not yet saved.\nPlease save the changes first before sending a test email."); + return false; + } + + const test_email = document.getElementById("smtp-test-email"); + + // Do a very very basic email address check. + if (test_email.value.match(/\S+@\S+/i) === null) { + test_email.parentElement.classList.add("was-validated"); + return false; + } + + const data = JSON.stringify({ "email": test_email.value }); + _post(`${BASE_URL}/admin/test/smtp/`, + "SMTP Test email sent correctly", + "Error sending SMTP test email", + data, false + ); +} + +function getFormData() { + let data = {}; + + document.querySelectorAll(".conf-checkbox").forEach(function (e) { + data[e.name] = e.checked; + }); + + document.querySelectorAll(".conf-number").forEach(function (e) { + data[e.name] = e.value ? +e.value : null; + }); + + document.querySelectorAll(".conf-text, .conf-password").forEach(function (e) { + data[e.name] = e.value || null; + }); + return data; +} + +function saveConfig() { + const data = JSON.stringify(getFormData()); + _post(`${BASE_URL}/admin/config/`, + "Config saved correctly", + "Error saving config", + data + ); + event.preventDefault(); +} + +function deleteConf() { + event.preventDefault(); + event.stopPropagation(); + const input = prompt( + "This will remove all user configurations, and restore the defaults and the " + + "values set by the environment. This operation could be dangerous. Type 'DELETE' to proceed:" + ); + if (input === "DELETE") { + _post(`${BASE_URL}/admin/config/delete`, + "Config deleted correctly", + "Error deleting config" + ); + } else { + alert("Wrong input, please try again"); + } +} + +function backupDatabase() { + event.preventDefault(); + event.stopPropagation(); + _post(`${BASE_URL}/admin/config/backup_db`, + "Backup created successfully", + "Error creating backup", null, false + ); +} + +// Two functions to help check if there were changes to the form fields +// Useful for example during the smtp test to prevent people from clicking save before testing there new settings +function initChangeDetection(form) { + const ignore_fields = ["smtp-test-email"]; + Array.from(form).forEach((el) => { + if (! ignore_fields.includes(el.id)) { + el.dataset.origValue = el.value; + } + }); +} + +function formHasChanges(form) { + return Array.from(form).some(el => "origValue" in el.dataset && ( el.dataset.origValue !== el.value)); +} + +// This function will prevent submitting a from when someone presses enter. +function preventFormSubmitOnEnter(form) { + form.onkeypress = function(e) { + const key = e.charCode || e.keyCode || 0; + if (key == 13) { + e.preventDefault(); + } + }; +} + +// This function will hook into the smtp-test-email input field and will call the smtpTest() function when enter is pressed. +function submitTestEmailOnEnter() { + const smtp_test_email_input = document.getElementById("smtp-test-email"); + smtp_test_email_input.onkeypress = function(e) { + const key = e.charCode || e.keyCode || 0; + if (key == 13) { + e.preventDefault(); + smtpTest(); + } + }; +} + +// Colorize some settings which are high risk +function colorRiskSettings() { + const risk_items = document.getElementsByClassName("col-form-label"); + Array.from(risk_items).forEach((el) => { + if (el.innerText.toLowerCase().includes("risks") ) { + el.parentElement.className += " alert-danger"; + } + }); +} + +function toggleVis(evt) { + event.preventDefault(); + event.stopPropagation(); + + const elem = document.getElementById(evt.target.dataset.vwPwToggle); + const type = elem.getAttribute("type"); + if (type === "text") { + elem.setAttribute("type", "password"); + } else { + elem.setAttribute("type", "text"); + } +} + +function masterCheck(check_id, inputs_query) { + function onChanged(checkbox, inputs_query) { + return function _fn() { + document.querySelectorAll(inputs_query).forEach(function (e) { e.disabled = !checkbox.checked; }); + checkbox.disabled = false; + }; + } + + const checkbox = document.getElementById(check_id); + const onChange = onChanged(checkbox, inputs_query); + onChange(); // Trigger the event initially + checkbox.addEventListener("change", onChange); +} + +const config_form = document.getElementById("config-form"); + +// onLoad events +document.addEventListener("DOMContentLoaded", (/*event*/) => { + initChangeDetection(config_form); + // Prevent enter to submitting the form and save the config. + // Users need to really click on save, this also to prevent accidental submits. + preventFormSubmitOnEnter(config_form); + + submitTestEmailOnEnter(); + colorRiskSettings(); + + document.querySelectorAll("input[id^='input__enable_']").forEach(group_toggle => { + const input_id = group_toggle.id.replace("input__enable_", "#g_"); + masterCheck(group_toggle.id, `${input_id} input`); + }); + + document.querySelectorAll("button[data-vw-pw-toggle]").forEach(password_toggle_btn => { + password_toggle_btn.addEventListener("click", toggleVis); + }); + + document.getElementById("backupDatabase").addEventListener("click", backupDatabase); + document.getElementById("deleteConf").addEventListener("click", deleteConf); + document.getElementById("smtpTest").addEventListener("click", smtpTest); + + config_form.addEventListener("submit", saveConfig); +}); \ No newline at end of file diff --git a/src/static/scripts/admin_users.js b/src/static/scripts/admin_users.js new file mode 100644 index 00000000..8f7ddf20 --- /dev/null +++ b/src/static/scripts/admin_users.js @@ -0,0 +1,246 @@ +"use strict"; + +function deleteUser() { + event.preventDefault(); + event.stopPropagation(); + const id = event.target.parentNode.dataset.vwUserUuid; + const email = event.target.parentNode.dataset.vwUserEmail; + if (!id || !email) { + alert("Required parameters not found!"); + return false; + } + const input_email = prompt(`To delete user "${email}", please type the email below`); + if (input_email != null) { + if (input_email == email) { + _post(`${BASE_URL}/admin/users/${id}/delete`, + "User deleted correctly", + "Error deleting user" + ); + } else { + alert("Wrong email, please try again"); + } + } +} + +function remove2fa() { + event.preventDefault(); + event.stopPropagation(); + const id = event.target.parentNode.dataset.vwUserUuid; + if (!id) { + alert("Required parameters not found!"); + return false; + } + _post(`${BASE_URL}/admin/users/${id}/remove-2fa`, + "2FA removed correctly", + "Error removing 2FA" + ); +} + +function deauthUser() { + event.preventDefault(); + event.stopPropagation(); + const id = event.target.parentNode.dataset.vwUserUuid; + if (!id) { + alert("Required parameters not found!"); + return false; + } + _post(`${BASE_URL}/admin/users/${id}/deauth`, + "Sessions deauthorized correctly", + "Error deauthorizing sessions" + ); +} + +function disableUser() { + event.preventDefault(); + event.stopPropagation(); + const id = event.target.parentNode.dataset.vwUserUuid; + const email = event.target.parentNode.dataset.vwUserEmail; + if (!id || !email) { + alert("Required parameters not found!"); + return false; + } + const confirmed = confirm(`Are you sure you want to disable user "${email}"? This will also deauthorize their sessions.`); + if (confirmed) { + _post(`${BASE_URL}/admin/users/${id}/disable`, + "User disabled successfully", + "Error disabling user" + ); + } +} + +function enableUser() { + event.preventDefault(); + event.stopPropagation(); + const id = event.target.parentNode.dataset.vwUserUuid; + const email = event.target.parentNode.dataset.vwUserEmail; + if (!id || !email) { + alert("Required parameters not found!"); + return false; + } + const confirmed = confirm(`Are you sure you want to enable user "${email}"?`); + if (confirmed) { + _post(`${BASE_URL}/admin/users/${id}/enable`, + "User enabled successfully", + "Error enabling user" + ); + } +} + +function updateRevisions() { + event.preventDefault(); + event.stopPropagation(); + _post(`${BASE_URL}/admin/users/update_revision`, + "Success, clients will sync next time they connect", + "Error forcing clients to sync" + ); +} + +function inviteUser() { + event.preventDefault(); + event.stopPropagation(); + const email = document.getElementById("inviteEmail"); + const data = JSON.stringify({ + "email": email.value + }); + email.value = ""; + _post(`${BASE_URL}/admin/invite/`, + "User invited correctly", + "Error inviting user", + data + ); +} + +const ORG_TYPES = { + "0": { + "name": "Owner", + "color": "orange" + }, + "1": { + "name": "Admin", + "color": "blueviolet" + }, + "2": { + "name": "User", + "color": "blue" + }, + "3": { + "name": "Manager", + "color": "green" + }, +}; + +// Special sort function to sort dates in ISO format +jQuery.extend(jQuery.fn.dataTableExt.oSort, { + "date-iso-pre": function(a) { + let x; + const sortDate = a.replace(/(<([^>]+)>)/gi, "").trim(); + if (sortDate !== "") { + const dtParts = sortDate.split(" "); + const timeParts = (undefined != dtParts[1]) ? dtParts[1].split(":") : ["00", "00", "00"]; + const dateParts = dtParts[0].split("-"); + x = (dateParts[0] + dateParts[1] + dateParts[2] + timeParts[0] + timeParts[1] + ((undefined != timeParts[2]) ? timeParts[2] : 0)) * 1; + if (isNaN(x)) { + x = 0; + } + } else { + x = Infinity; + } + return x; + }, + + "date-iso-asc": function(a, b) { + return a - b; + }, + + "date-iso-desc": function(a, b) { + return b - a; + } +}); + +const userOrgTypeDialog = document.getElementById("userOrgTypeDialog"); +// Fill the form and title +userOrgTypeDialog.addEventListener("show.bs.modal", function(event) { + // Get shared values + const userEmail = event.relatedTarget.parentNode.dataset.vwUserEmail; + const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid; + // Get org specific values + const userOrgType = event.relatedTarget.dataset.vwOrgType; + const userOrgTypeName = ORG_TYPES[userOrgType]["name"]; + const orgName = event.relatedTarget.dataset.vwOrgName; + const orgUuid = event.relatedTarget.dataset.vwOrgUuid; + + document.getElementById("userOrgTypeDialogTitle").innerHTML = `Update User Type:
Organization: ${orgName}
User: ${userEmail}`; + document.getElementById("userOrgTypeUserUuid").value = userUuid; + document.getElementById("userOrgTypeOrgUuid").value = orgUuid; + document.getElementById(`userOrgType${userOrgTypeName}`).checked = true; +}, false); + +// Prevent accidental submission of the form with valid elements after the modal has been hidden. +userOrgTypeDialog.addEventListener("hide.bs.modal", function() { + document.getElementById("userOrgTypeDialogTitle").innerHTML = ""; + document.getElementById("userOrgTypeUserUuid").value = ""; + document.getElementById("userOrgTypeOrgUuid").value = ""; +}, false); + +function updateUserOrgType() { + event.preventDefault(); + event.stopPropagation(); + + const data = JSON.stringify(Object.fromEntries(new FormData(event.target).entries())); + + _post(`${BASE_URL}/admin/users/org_type`, + "Updated organization type of the user successfully", + "Error updating organization type of the user", + data + ); +} + +// onLoad events +document.addEventListener("DOMContentLoaded", (/*event*/) => { + jQuery("#users-table").DataTable({ + "stateSave": true, + "responsive": true, + "lengthMenu": [ + [-1, 5, 10, 25, 50], + ["All", 5, 10, 25, 50] + ], + "pageLength": -1, // Default show all + "columnDefs": [{ + "targets": [1, 2], + "type": "date-iso" + }, { + "targets": 6, + "searchable": false, + "orderable": false + }] + }); + + // Color all the org buttons per type + document.querySelectorAll("button[data-vw-org-type]").forEach(function(e) { + const orgType = ORG_TYPES[e.dataset.vwOrgType]; + e.style.backgroundColor = orgType.color; + e.title = orgType.name; + }); + + // Add click events for user actions + document.querySelectorAll("button[vw-remove2fa]").forEach(btn => { + btn.addEventListener("click", remove2fa); + }); + document.querySelectorAll("button[vw-deauth-user]").forEach(btn => { + btn.addEventListener("click", deauthUser); + }); + document.querySelectorAll("button[vw-delete-user]").forEach(btn => { + btn.addEventListener("click", deleteUser); + }); + document.querySelectorAll("button[vw-disable-user]").forEach(btn => { + btn.addEventListener("click", disableUser); + }); + document.querySelectorAll("button[vw-enable-user]").forEach(btn => { + btn.addEventListener("click", enableUser); + }); + + document.getElementById("updateRevisions").addEventListener("click", updateRevisions); + document.getElementById("reload").addEventListener("click", reload); + document.getElementById("userOrgTypeForm").addEventListener("submit", updateUserOrgType); + document.getElementById("inviteUserForm").addEventListener("submit", inviteUser); +}); \ No newline at end of file diff --git a/src/static/scripts/bootstrap.css b/src/static/scripts/bootstrap.css index fa2da29b..614c226f 100644 --- a/src/static/scripts/bootstrap.css +++ b/src/static/scripts/bootstrap.css @@ -10874,5 +10874,3 @@ textarea.form-control-lg { display: none !important; } } - -/*# sourceMappingURL=bootstrap.css.map */ \ No newline at end of file diff --git a/src/static/templates/404.hbs b/src/static/templates/404.hbs index 230c30ca..064dc5a1 100644 --- a/src/static/templates/404.hbs +++ b/src/static/templates/404.hbs @@ -7,31 +7,7 @@ Page not found! - + @@ -53,7 +29,7 @@

Page not found!

Sorry, but the page you were looking for could not be found.

- Return to the web vault?

+ Return to the web vault?

You can return to the web-vault, or contact us.

diff --git a/src/static/templates/admin/base.hbs b/src/static/templates/admin/base.hbs index ba3e88d8..9b033b16 100644 --- a/src/static/templates/admin/base.hbs +++ b/src/static/templates/admin/base.hbs @@ -7,86 +7,9 @@ Vaultwarden Admin Panel - - + + -