commit 2d66292350d352c3ea41db08d4c2de33898e9806 parent adf67a8ee887d21e104adfa8b6521d7971f5a1f1 Author: GeekCornerGH <45696571+GeekCornerGH@users.noreply.github.com> Date: Sun, 11 Jun 2023 13:28:18 +0200 feat: Push Notifications Co-authored-by: samb-devel <125741162+samb-devel@users.noreply.github.com> Co-authored-by: Zoruk <Zoruk@users.noreply.github.com> Diffstat:
22 files changed, 529 insertions(+), 70 deletions(-)
diff --git a/.env.template b/.env.template @@ -72,6 +72,13 @@ # WEBSOCKET_ADDRESS=0.0.0.0 # WEBSOCKET_PORT=3012 +## Enables push notifications (requires key and id from https://bitwarden.com/host) +# PUSH_ENABLED=true +# PUSH_INSTALLATION_ID=CHANGEME +# PUSH_INSTALLATION_KEY=CHANGEME +## Don't change this unless you know what you're doing. +# PUSH_RELAY_BASE_URI=https://push.bitwarden.com + ## Controls whether users are allowed to create Bitwarden Sends. ## This setting applies globally to all users. ## To control this on a per-org basis instead, use the "Disable Send" org policy. diff --git a/migrations/mysql/2023-02-18-125735_push_uuid_table/down.sql b/migrations/mysql/2023-02-18-125735_push_uuid_table/down.sql diff --git a/migrations/mysql/2023-02-18-125735_push_uuid_table/up.sql b/migrations/mysql/2023-02-18-125735_push_uuid_table/up.sql @@ -0,0 +1 @@ +ALTER TABLE devices ADD COLUMN push_uuid TEXT; +\ No newline at end of file diff --git a/migrations/postgresql/2023-02-18-125735_push_uuid_table/down.sql b/migrations/postgresql/2023-02-18-125735_push_uuid_table/down.sql diff --git a/migrations/postgresql/2023-02-18-125735_push_uuid_table/up.sql b/migrations/postgresql/2023-02-18-125735_push_uuid_table/up.sql @@ -0,0 +1 @@ +ALTER TABLE devices ADD COLUMN push_uuid TEXT; +\ No newline at end of file diff --git a/migrations/sqlite/2023-02-18-125735_push_uuid_table/down.sql b/migrations/sqlite/2023-02-18-125735_push_uuid_table/down.sql diff --git a/migrations/sqlite/2023-02-18-125735_push_uuid_table/up.sql b/migrations/sqlite/2023-02-18-125735_push_uuid_table/up.sql @@ -0,0 +1 @@ +ALTER TABLE devices ADD COLUMN push_uuid TEXT; +\ No newline at end of file diff --git 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, Notify, NumberOrString}, + api::{core::log_event, unregister_push_device, ApiResult, EmptyResult, JsonResult, Notify, NumberOrString}, auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp}, config::ConfigBuilder, db::{backup_database, get_sql_server_version, models::*, DbConn, DbConnType}, @@ -402,14 +402,22 @@ async fn delete_user(uuid: &str, token: AdminToken, mut conn: DbConn) -> EmptyRe #[post("/users/<uuid>/deauth")] async fn deauth_user(uuid: &str, _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(); - let save_result = user.save(&mut conn).await; + nt.send_logout(&user, None, &mut conn).await; - nt.send_logout(&user, None).await; + if CONFIG.push_enabled() { + for device in Device::find_push_device_by_user(&user.uuid, &mut conn).await { + match unregister_push_device(device.uuid).await { + Ok(r) => r, + Err(e) => error!("Unable to unregister devices from Bitwarden server: {}", e), + }; + } + } - save_result + Device::delete_all_by_user(&user.uuid, &mut conn).await?; + user.reset_security_stamp(); + + user.save(&mut conn).await } #[post("/users/<uuid>/disable")] @@ -421,7 +429,7 @@ async fn disable_user(uuid: &str, _token: AdminToken, mut conn: DbConn, nt: Noti let save_result = user.save(&mut conn).await; - nt.send_logout(&user, None).await; + nt.send_logout(&user, None, &mut conn).await; save_result } diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs @@ -4,7 +4,8 @@ use serde_json::Value; use crate::{ api::{ - core::log_user_event, EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType, + core::log_user_event, register_push_device, unregister_push_device, EmptyResult, JsonResult, JsonUpcase, + Notify, NumberOrString, PasswordData, UpdateType, }, auth::{decode_delete, decode_invite, decode_verify_email, Headers}, crypto, @@ -35,6 +36,7 @@ pub fn routes() -> Vec<rocket::Route> { post_verify_email_token, post_delete_recover, post_delete_recover_token, + post_device_token, delete_account, post_delete_account, revision_date, @@ -46,6 +48,9 @@ pub fn routes() -> Vec<rocket::Route> { get_known_device, get_known_device_from_path, put_avatar, + put_device_token, + put_clear_device_token, + post_clear_device_token, ] } @@ -338,7 +343,7 @@ async fn post_password( // Prevent loging out the client where the user requested this endpoint from. // If you do logout the user it will causes issues at the client side. // Adding the device uuid will prevent this. - nt.send_logout(&user, Some(headers.device.uuid)).await; + nt.send_logout(&user, Some(headers.device.uuid), &mut conn).await; save_result } @@ -398,7 +403,7 @@ async fn post_kdf(data: JsonUpcase<ChangeKdfData>, headers: Headers, mut conn: D user.set_password(&data.NewMasterPasswordHash, Some(data.Key), true, None); let save_result = user.save(&mut conn).await; - nt.send_logout(&user, Some(headers.device.uuid)).await; + nt.send_logout(&user, Some(headers.device.uuid), &mut conn).await; save_result } @@ -485,7 +490,7 @@ async fn post_rotatekey(data: JsonUpcase<KeyData>, headers: Headers, mut conn: D // Prevent loging out the client where the user requested this endpoint from. // If you do logout the user it will causes issues at the client side. // Adding the device uuid will prevent this. - nt.send_logout(&user, Some(headers.device.uuid)).await; + nt.send_logout(&user, Some(headers.device.uuid), &mut conn).await; save_result } @@ -508,7 +513,7 @@ async fn post_sstamp( user.reset_security_stamp(); let save_result = user.save(&mut conn).await; - nt.send_logout(&user, None).await; + nt.send_logout(&user, None, &mut conn).await; save_result } @@ -611,7 +616,7 @@ async fn post_email( let save_result = user.save(&mut conn).await; - nt.send_logout(&user, None).await; + nt.send_logout(&user, None, &mut conn).await; save_result } @@ -930,3 +935,64 @@ impl<'r> FromRequest<'r> for KnownDevice { }) } } + +#[derive(Deserialize)] +#[allow(non_snake_case)] +struct PushToken { + PushToken: String, +} + +#[post("/devices/identifier/<uuid>/token", data = "<data>")] +async fn post_device_token(uuid: &str, data: JsonUpcase<PushToken>, headers: Headers, conn: DbConn) -> EmptyResult { + put_device_token(uuid, data, headers, conn).await +} + +#[put("/devices/identifier/<uuid>/token", data = "<data>")] +async fn put_device_token(uuid: &str, data: JsonUpcase<PushToken>, headers: Headers, mut conn: DbConn) -> EmptyResult { + if !CONFIG.push_enabled() { + return Ok(()); + } + + let data = data.into_inner().data; + let token = data.PushToken; + let mut device = match Device::find_by_uuid_and_user(&headers.device.uuid, &headers.user.uuid, &mut conn).await { + Some(device) => device, + None => err!(format!("Error: device {uuid} should be present before a token can be assigned")), + }; + device.push_token = Some(token); + if device.push_uuid.is_none() { + device.push_uuid = Some(uuid::Uuid::new_v4().to_string()); + } + if let Err(e) = device.save(&mut conn).await { + err!(format!("An error occured while trying to save the device push token: {e}")); + } + if let Err(e) = register_push_device(headers.user.uuid, device).await { + err!(format!("An error occured while proceeding registration of a device: {e}")); + } + + Ok(()) +} + +#[put("/devices/identifier/<uuid>/clear-token")] +async fn put_clear_device_token(uuid: &str, mut conn: DbConn) -> EmptyResult { + // This only clears push token + // https://github.com/bitwarden/core/blob/master/src/Api/Controllers/DevicesController.cs#L109 + // https://github.com/bitwarden/core/blob/master/src/Core/Services/Implementations/DeviceService.cs#L37 + // This is somehow not implemented in any app, added it in case it is required + if !CONFIG.push_enabled() { + return Ok(()); + } + + if let Some(device) = Device::find_by_uuid(uuid, &mut conn).await { + Device::clear_push_token_by_uuid(uuid, &mut conn).await?; + unregister_push_device(device.uuid).await?; + } + + Ok(()) +} + +// On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere +#[post("/devices/identifier/<uuid>/clear-token")] +async fn post_clear_device_token(uuid: &str, conn: DbConn) -> EmptyResult { + put_clear_device_token(uuid, conn).await +} diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs @@ -511,10 +511,9 @@ pub async fn update_cipher_from_data( ) .await; } - - nt.send_cipher_update(ut, cipher, &cipher.update_users_revision(conn).await, &headers.device.uuid, None).await; + nt.send_cipher_update(ut, cipher, &cipher.update_users_revision(conn).await, &headers.device.uuid, None, conn) + .await; } - Ok(()) } @@ -580,6 +579,7 @@ async fn post_ciphers_import( let mut user = headers.user; user.update_revision(&mut conn).await?; nt.send_user_update(UpdateType::SyncVault, &user).await; + Ok(()) } @@ -777,6 +777,7 @@ async fn post_collections_admin( &cipher.update_users_revision(&mut conn).await, &headers.device.uuid, Some(Vec::from_iter(posted_collections)), + &mut conn, ) .await; @@ -1122,6 +1123,7 @@ async fn save_attachment( &cipher.update_users_revision(&mut conn).await, &headers.device.uuid, None, + &mut conn, ) .await; @@ -1407,8 +1409,15 @@ async fn move_cipher_selected( // Move cipher cipher.move_to_folder(data.FolderId.clone(), &user_uuid, &mut conn).await?; - nt.send_cipher_update(UpdateType::SyncCipherUpdate, &cipher, &[user_uuid.clone()], &headers.device.uuid, None) - .await; + nt.send_cipher_update( + UpdateType::SyncCipherUpdate, + &cipher, + &[user_uuid.clone()], + &headers.device.uuid, + None, + &mut conn, + ) + .await; } Ok(()) @@ -1489,6 +1498,7 @@ async fn delete_all( user.update_revision(&mut conn).await?; nt.send_user_update(UpdateType::SyncVault, &user).await; + Ok(()) } } @@ -1519,6 +1529,7 @@ async fn _delete_cipher_by_uuid( &cipher.update_users_revision(conn).await, &headers.device.uuid, None, + conn, ) .await; } else { @@ -1529,6 +1540,7 @@ async fn _delete_cipher_by_uuid( &cipher.update_users_revision(conn).await, &headers.device.uuid, None, + conn, ) .await; } @@ -1599,8 +1611,10 @@ async fn _restore_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &mut DbCon &cipher.update_users_revision(conn).await, &headers.device.uuid, None, + conn, ) .await; + if let Some(org_uuid) = &cipher.organization_uuid { log_event( EventType::CipherRestored as i32, @@ -1681,8 +1695,10 @@ async fn _delete_cipher_attachment_by_id( &cipher.update_users_revision(conn).await, &headers.device.uuid, None, + conn, ) .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 @@ -50,7 +50,7 @@ async fn post_folders(data: JsonUpcase<FolderData>, headers: Headers, mut conn: let mut folder = Folder::new(headers.user.uuid, data.Name); folder.save(&mut conn).await?; - nt.send_folder_update(UpdateType::SyncFolderCreate, &folder, &headers.device.uuid).await; + nt.send_folder_update(UpdateType::SyncFolderCreate, &folder, &headers.device.uuid, &mut conn).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::SyncFolderUpdate, &folder, &headers.device.uuid).await; + nt.send_folder_update(UpdateType::SyncFolderUpdate, &folder, &headers.device.uuid, &mut conn).await; Ok(Json(folder.to_json())) } @@ -112,6 +112,6 @@ async fn delete_folder(uuid: &str, headers: Headers, mut conn: DbConn, nt: Notif // Delete the actual folder entry folder.delete(&mut conn).await?; - nt.send_folder_update(UpdateType::SyncFolderDelete, &folder, &headers.device.uuid).await; + nt.send_folder_update(UpdateType::SyncFolderDelete, &folder, &headers.device.uuid, &mut conn).await; Ok(()) } diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs @@ -14,7 +14,6 @@ pub use sends::purge_sends; pub use two_factor::send_incomplete_2fa_notifications; pub fn routes() -> Vec<Route> { - let mut device_token_routes = routes![clear_device_token, put_device_token]; let mut eq_domains_routes = routes![get_eq_domains, post_eq_domains, put_eq_domains]; let mut hibp_routes = routes![hibp_breach]; let mut meta_routes = routes![alive, now, version, config]; @@ -28,7 +27,6 @@ pub fn routes() -> Vec<Route> { routes.append(&mut organizations::routes()); routes.append(&mut two_factor::routes()); routes.append(&mut sends::routes()); - routes.append(&mut device_token_routes); routes.append(&mut eq_domains_routes); routes.append(&mut hibp_routes); routes.append(&mut meta_routes); @@ -57,37 +55,6 @@ use crate::{ util::get_reqwest_client, }; -#[put("/devices/identifier/<uuid>/clear-token")] -fn clear_device_token(uuid: &str) -> &'static str { - // This endpoint doesn't have auth header - - let _ = uuid; - // uuid is not related to deviceId - - // This only clears push token - // https://github.com/bitwarden/core/blob/master/src/Api/Controllers/DevicesController.cs#L109 - // https://github.com/bitwarden/core/blob/master/src/Core/Services/Implementations/DeviceService.cs#L37 - "" -} - -#[put("/devices/identifier/<uuid>/token", data = "<data>")] -fn put_device_token(uuid: &str, data: JsonUpcase<Value>, headers: Headers) -> Json<Value> { - let _data: Value = data.into_inner().data; - // Data has a single string value "PushToken" - let _ = uuid; - // uuid is not related to deviceId - - // TODO: This should save the push token, but we don't have push functionality - - Json(json!({ - "Id": headers.device.uuid, - "Name": headers.device.name, - "Type": headers.device.atype, - "Identifier": headers.device.uuid, - "CreationDate": crate::util::format_date(&headers.device.created_at), - })) -} - #[derive(Serialize, Deserialize, Debug)] #[allow(non_snake_case)] struct GlobalDomain { diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs @@ -2716,7 +2716,7 @@ async fn put_reset_password( user.set_password(reset_request.NewMasterPasswordHash.as_str(), Some(reset_request.Key), true, None); user.save(&mut conn).await?; - nt.send_logout(&user, None).await; + nt.send_logout(&user, None, &mut conn).await; log_event( EventType::OrganizationUserAdminResetPassword as i32, diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs @@ -180,7 +180,8 @@ async fn post_send(data: JsonUpcase<SendData>, headers: Headers, mut conn: DbCon let mut send = create_send(data, headers.user.uuid)?; send.save(&mut conn).await?; - nt.send_send_update(UpdateType::SyncSendCreate, &send, &send.update_users_revision(&mut conn).await).await; + nt.send_send_update(UpdateType::SyncSendCreate, &send, &send.update_users_revision(&mut conn).await, &mut conn) + .await; Ok(Json(send.to_json())) } @@ -252,7 +253,8 @@ async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, mut conn: // Save the changes in the database send.save(&mut conn).await?; - nt.send_send_update(UpdateType::SyncSendCreate, &send, &send.update_users_revision(&mut conn).await).await; + nt.send_send_update(UpdateType::SyncSendCreate, &send, &send.update_users_revision(&mut conn).await, &mut conn) + .await; Ok(Json(send.to_json())) } @@ -335,7 +337,8 @@ async fn post_send_file_v2_data( data.data.move_copy_to(file_path).await? } - nt.send_send_update(UpdateType::SyncSendCreate, &send, &send.update_users_revision(&mut conn).await).await; + nt.send_send_update(UpdateType::SyncSendCreate, &send, &send.update_users_revision(&mut conn).await, &mut conn) + .await; } else { err!("Send not found. Unable to save the file."); } @@ -397,7 +400,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; + nt.send_send_update(UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&mut conn).await, &mut conn) + .await; Ok(Json(send.to_json_access(&mut conn).await)) } @@ -448,7 +452,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; + nt.send_send_update(UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&mut conn).await, &mut conn) + .await; let token_claims = crate::auth::generate_send_claims(send_id, file_id); let token = crate::auth::encode_jwt(&token_claims); @@ -530,7 +535,8 @@ async fn put_send( } send.save(&mut conn).await?; - nt.send_send_update(UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&mut conn).await).await; + nt.send_send_update(UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&mut conn).await, &mut conn) + .await; Ok(Json(send.to_json())) } @@ -547,7 +553,8 @@ async fn delete_send(id: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_ } send.delete(&mut conn).await?; - nt.send_send_update(UpdateType::SyncSendDelete, &send, &send.update_users_revision(&mut conn).await).await; + nt.send_send_update(UpdateType::SyncSendDelete, &send, &send.update_users_revision(&mut conn).await, &mut conn) + .await; Ok(()) } @@ -567,7 +574,8 @@ async fn put_remove_password(id: &str, headers: Headers, mut conn: DbConn, nt: N send.set_password(None); send.save(&mut conn).await?; - nt.send_send_update(UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&mut conn).await).await; + nt.send_send_update(UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&mut conn).await, &mut conn) + .await; Ok(Json(send.to_json())) } diff --git a/src/api/mod.rs b/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod core; mod icons; mod identity; mod notifications; +mod push; mod web; use rocket::serde::json::Json; @@ -22,6 +23,10 @@ pub use crate::api::{ identity::routes as identity_routes, notifications::routes as notifications_routes, notifications::{start_notification_server, Notify, UpdateType}, + push::{ + push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device, + unregister_push_device, + }, web::catchers as web_catchers, web::routes as web_routes, web::static_files, diff --git a/src/api/notifications.rs b/src/api/notifications.rs @@ -21,7 +21,10 @@ use tokio_tungstenite::{ use crate::{ auth::ClientIp, - db::models::{Cipher, Folder, Send as DbSend, User}, + db::{ + models::{Cipher, Folder, Send as DbSend, User}, + DbConn, + }, Error, CONFIG, }; @@ -33,6 +36,8 @@ static WS_USERS: Lazy<Arc<WebSocketUsers>> = Lazy::new(|| { }) }); +use super::{push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update}; + pub fn routes() -> Vec<Route> { routes![websockets_hub] } @@ -233,19 +238,33 @@ impl WebSocketUsers { ); self.send_update(&user.uuid, &data).await; + + if CONFIG.push_enabled() { + push_user_update(ut, user).await; + } } - pub async fn send_logout(&self, user: &User, acting_device_uuid: Option<String>) { + pub async fn send_logout(&self, user: &User, acting_device_uuid: Option<String>, conn: &mut DbConn) { let data = create_update( vec![("UserId".into(), user.uuid.clone().into()), ("Date".into(), serialize_date(user.updated_at))], UpdateType::LogOut, - acting_device_uuid, + acting_device_uuid.clone(), ); self.send_update(&user.uuid, &data).await; + + if CONFIG.push_enabled() { + push_logout(user, acting_device_uuid, conn).await; + } } - pub async fn send_folder_update(&self, ut: UpdateType, folder: &Folder, acting_device_uuid: &String) { + pub async fn send_folder_update( + &self, + ut: UpdateType, + folder: &Folder, + acting_device_uuid: &String, + conn: &mut DbConn, + ) { let data = create_update( vec![ ("Id".into(), folder.uuid.clone().into()), @@ -257,6 +276,10 @@ impl WebSocketUsers { ); self.send_update(&folder.user_uuid, &data).await; + + if CONFIG.push_enabled() { + push_folder_update(ut, folder, acting_device_uuid, conn).await; + } } pub async fn send_cipher_update( @@ -266,6 +289,7 @@ impl WebSocketUsers { user_uuids: &[String], acting_device_uuid: &String, collection_uuids: Option<Vec<String>>, + conn: &mut DbConn, ) { let org_uuid = convert_option(cipher.organization_uuid.clone()); // Depending if there are collections provided or not, we need to have different values for the following variables. @@ -295,9 +319,13 @@ impl WebSocketUsers { for uuid in user_uuids { self.send_update(uuid, &data).await; } + + if CONFIG.push_enabled() && user_uuids.len() == 1 { + push_cipher_update(ut, cipher, acting_device_uuid, conn).await; + } } - pub async fn send_send_update(&self, ut: UpdateType, send: &DbSend, user_uuids: &[String]) { + pub async fn send_send_update(&self, ut: UpdateType, send: &DbSend, user_uuids: &[String], conn: &mut DbConn) { let user_uuid = convert_option(send.user_uuid.clone()); let data = create_update( @@ -313,6 +341,9 @@ impl WebSocketUsers { for uuid in user_uuids { self.send_update(uuid, &data).await; } + if CONFIG.push_enabled() && user_uuids.len() == 1 { + push_send_update(ut, send, conn).await; + } } } @@ -354,7 +385,7 @@ fn create_ping() -> Vec<u8> { } #[allow(dead_code)] -#[derive(Eq, PartialEq)] +#[derive(Copy, Clone, Eq, PartialEq)] pub enum UpdateType { SyncCipherUpdate = 0, SyncCipherCreate = 1, diff --git a/src/api/push.rs b/src/api/push.rs @@ -0,0 +1,280 @@ +use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; +use serde_json::Value; +use tokio::sync::RwLock; + +use crate::{ + api::{ApiResult, EmptyResult, UpdateType}, + db::models::{Cipher, Device, Folder, Send, User}, + util::get_reqwest_client, + CONFIG, +}; + +use once_cell::sync::Lazy; +use std::time::{Duration, Instant}; + +#[derive(Deserialize)] +struct AuthPushToken { + access_token: String, + expires_in: i32, +} + +#[derive(Debug)] +struct LocalAuthPushToken { + access_token: String, + valid_until: Instant, +} + +async fn get_auth_push_token() -> ApiResult<String> { + static PUSH_TOKEN: Lazy<RwLock<LocalAuthPushToken>> = Lazy::new(|| { + RwLock::new(LocalAuthPushToken { + access_token: String::new(), + valid_until: Instant::now(), + }) + }); + let push_token = PUSH_TOKEN.read().await; + + if push_token.valid_until.saturating_duration_since(Instant::now()).as_secs() > 0 { + debug!("Auth Push token still valid, no need for a new one"); + return Ok(push_token.access_token.clone()); + } + drop(push_token); // Drop the read lock now + + let installation_id = CONFIG.push_installation_id(); + let client_id = format!("installation.{installation_id}"); + let client_secret = CONFIG.push_installation_key(); + + let params = [ + ("grant_type", "client_credentials"), + ("scope", "api.push"), + ("client_id", &client_id), + ("client_secret", &client_secret), + ]; + + let res = match get_reqwest_client().post("https://identity.bitwarden.com/connect/token").form(¶ms).send().await + { + Ok(r) => r, + Err(e) => err!(format!("Error getting push token from bitwarden server: {e}")), + }; + + let json_pushtoken = match res.json::<AuthPushToken>().await { + Ok(r) => r, + Err(e) => err!(format!("Unexpected push token received from bitwarden server: {e}")), + }; + + let mut push_token = PUSH_TOKEN.write().await; + push_token.valid_until = Instant::now() + .checked_add(Duration::new((json_pushtoken.expires_in / 2) as u64, 0)) // Token valid for half the specified time + .unwrap(); + + push_token.access_token = json_pushtoken.access_token; + + debug!("Token still valid for {}", push_token.valid_until.saturating_duration_since(Instant::now()).as_secs()); + Ok(push_token.access_token.clone()) +} + +pub async fn register_push_device(user_uuid: String, device: Device) -> EmptyResult { + if !CONFIG.push_enabled() { + return Ok(()); + } + let auth_push_token = get_auth_push_token().await?; + + //Needed to register a device for push to bitwarden : + let data = json!({ + "userId": user_uuid, + "deviceId": device.push_uuid, + "identifier": device.uuid, + "type": device.atype, + "pushToken": device.push_token + }); + + let auth_header = format!("Bearer {}", &auth_push_token); + + get_reqwest_client() + .post(CONFIG.push_relay_uri() + "/push/register") + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json") + .header(AUTHORIZATION, auth_header) + .json(&data) + .send() + .await? + .error_for_status()?; + Ok(()) +} + +pub async fn unregister_push_device(uuid: String) -> EmptyResult { + if !CONFIG.push_enabled() { + return Ok(()); + } + let auth_push_token = get_auth_push_token().await?; + + let auth_header = format!("Bearer {}", &auth_push_token); + + match get_reqwest_client() + .delete(CONFIG.push_relay_uri() + "/push/" + &uuid) + .header(AUTHORIZATION, auth_header) + .send() + .await + { + Ok(r) => r, + Err(e) => err!(format!("An error occured during device unregistration: {e}")), + }; + Ok(()) +} + +pub async fn push_cipher_update( + ut: UpdateType, + cipher: &Cipher, + acting_device_uuid: &String, + conn: &mut crate::db::DbConn, +) { + // We shouldn't send a push notification on cipher update if the cipher belongs to an organization, this isn't implemented in the upstream server too. + if cipher.organization_uuid.is_some() { + return; + }; + let user_uuid = match &cipher.user_uuid { + Some(c) => c, + None => { + debug!("Cipher has no uuid"); + return; + } + }; + + for device in Device::find_by_user(user_uuid, conn).await { + let data = json!({ + "userId": user_uuid, + "organizationId": (), + "deviceId": device.push_uuid, + "identifier": acting_device_uuid, + "type": ut as i32, + "payload": { + "Id": cipher.uuid, + "UserId": cipher.user_uuid, + "OrganizationId": (), + "RevisionDate": cipher.updated_at + } + }); + + send_to_push_relay(data).await; + } +} + +pub async fn push_logout(user: &User, acting_device_uuid: Option<String>, conn: &mut crate::db::DbConn) { + if let Some(d) = acting_device_uuid { + for device in Device::find_by_user(&user.uuid, conn).await { + let data = json!({ + "userId": user.uuid, + "organizationId": (), + "deviceId": device.push_uuid, + "identifier": d, + "type": UpdateType::LogOut as i32, + "payload": { + "UserId": user.uuid, + "Date": user.updated_at + } + }); + send_to_push_relay(data).await; + } + } else { + let data = json!({ + "userId": user.uuid, + "organizationId": (), + "deviceId": (), + "identifier": (), + "type": UpdateType::LogOut as i32, + "payload": { + "UserId": user.uuid, + "Date": user.updated_at + } + }); + send_to_push_relay(data).await; + } +} + +pub async fn push_user_update(ut: UpdateType, user: &User) { + let data = json!({ + "userId": user.uuid, + "organizationId": (), + "deviceId": (), + "identifier": (), + "type": ut as i32, + "payload": { + "UserId": user.uuid, + "Date": user.updated_at + } + }); + + send_to_push_relay(data).await; +} + +pub async fn push_folder_update( + ut: UpdateType, + folder: &Folder, + acting_device_uuid: &String, + conn: &mut crate::db::DbConn, +) { + for device in Device::find_by_user(&folder.user_uuid, conn).await { + let data = json!({ + "userId": folder.user_uuid, + "organizationId": (), + "deviceId": device.push_uuid, + "identifier": acting_device_uuid, + "type": ut as i32, + "payload": { + "Id": folder.uuid, + "UserId": folder.user_uuid, + "RevisionDate": folder.updated_at + } + }); + + send_to_push_relay(data).await; + } +} + +pub async fn push_send_update(ut: UpdateType, send: &Send, conn: &mut crate::db::DbConn) { + if let Some(s) = &send.user_uuid { + for device in Device::find_by_user(s, conn).await { + let data = json!({ + "userId": send.user_uuid, + "organizationId": (), + "deviceId": device.push_uuid, + "identifier": (), + "type": ut as i32, + "payload": { + "Id": send.uuid, + "UserId": send.user_uuid, + "RevisionDate": send.revision_date + } + }); + + send_to_push_relay(data).await; + } + } +} + +async fn send_to_push_relay(data: Value) { + if !CONFIG.push_enabled() { + return; + } + + let auth_push_token = match get_auth_push_token().await { + Ok(s) => s, + Err(e) => { + debug!("Could not get the auth push token: {}", e); + return; + } + }; + + let auth_header = format!("Bearer {}", &auth_push_token); + + if let Err(e) = get_reqwest_client() + .post(CONFIG.push_relay_uri() + "/push/send") + .header(ACCEPT, "application/json") + .header(CONTENT_TYPE, "application/json") + .header(AUTHORIZATION, auth_header) + .json(&data) + .send() + .await + { + error!("An error occured while sending a send update to the push relay: {}", e); + }; +} diff --git a/src/config.rs b/src/config.rs @@ -377,6 +377,16 @@ make_config! { /// Websocket port websocket_port: u16, false, def, 3012; }, + push { + /// Enable push notifications + push_enabled: bool, false, def, false; + /// Push relay base uri + push_relay_uri: String, false, def, "https://push.bitwarden.com".to_string(); + /// Installation id |> The installation id from https://bitwarden.com/host + push_installation_id: Pass, false, def, String::new(); + /// Installation key |> The installation key from https://bitwarden.com/host + push_installation_key: Pass, false, def, String::new(); + }, jobs { /// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run. /// Set to 0 to globally disable scheduled jobs. @@ -724,6 +734,17 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { } } + if cfg.push_enabled && (cfg.push_installation_id == String::new() || cfg.push_installation_key == String::new()) { + err!( + "Misconfigured Push Notification service\n\ + ########################################################################################\n\ + # It looks like you enabled Push Notification feature, but didn't configure it #\n\ + # properly. Make sure the installation id and key from https://bitwarden.com/host are #\n\ + # added to your configuration. #\n\ + ########################################################################################\n" + ) + } + if cfg._enable_duo && (cfg.duo_host.is_some() || cfg.duo_ikey.is_some() || cfg.duo_skey.is_some()) && !(cfg.duo_host.is_some() && cfg.duo_ikey.is_some() && cfg.duo_skey.is_some()) diff --git a/src/db/models/device.rs b/src/db/models/device.rs @@ -15,7 +15,8 @@ db_object! { pub user_uuid: String, pub name: String, - pub atype: i32, // https://github.com/bitwarden/server/blob/master/src/Core/Enums/DeviceType.cs + pub atype: i32, // https://github.com/bitwarden/server/blob/master/src/Core/Enums/DeviceType.cs + pub push_uuid: Option<String>, pub push_token: Option<String>, pub refresh_token: String, @@ -38,6 +39,7 @@ impl Device { name, atype, + push_uuid: None, push_token: None, refresh_token: String::new(), twofactor_remember: None, @@ -155,6 +157,35 @@ impl Device { }} } + pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> { + db_run! { conn: { + devices::table + .filter(devices::user_uuid.eq(user_uuid)) + .load::<DeviceDb>(conn) + .expect("Error loading devices") + .from_db() + }} + } + + pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option<Self> { + db_run! { conn: { + devices::table + .filter(devices::uuid.eq(uuid)) + .first::<DeviceDb>(conn) + .ok() + .from_db() + }} + } + + pub async fn clear_push_token_by_uuid(uuid: &str, conn: &mut DbConn) -> EmptyResult { + db_run! { conn: { + diesel::update(devices::table) + .filter(devices::uuid.eq(uuid)) + .set(devices::push_token.eq::<Option<String>>(None)) + .execute(conn) + .map_res("Error removing push token") + }} + } pub async fn find_by_refresh_token(refresh_token: &str, conn: &mut DbConn) -> Option<Self> { db_run! { conn: { devices::table @@ -175,4 +206,14 @@ impl Device { .from_db() }} } + pub async fn find_push_device_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> { + db_run! { conn: { + devices::table + .filter(devices::user_uuid.eq(user_uuid)) + .filter(devices::push_token.is_not_null()) + .load::<DeviceDb>(conn) + .expect("Error loading push devices") + .from_db() + }} + } } diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs @@ -49,6 +49,7 @@ table! { user_uuid -> Text, name -> Text, atype -> Integer, + push_uuid -> Nullable<Text>, push_token -> Nullable<Text>, refresh_token -> Text, twofactor_remember -> Nullable<Text>, diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs @@ -49,6 +49,7 @@ table! { user_uuid -> Text, name -> Text, atype -> Integer, + push_uuid -> Nullable<Text>, push_token -> Nullable<Text>, refresh_token -> Text, twofactor_remember -> Nullable<Text>, diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs @@ -49,6 +49,7 @@ table! { user_uuid -> Text, name -> Text, atype -> Integer, + push_uuid -> Nullable<Text>, push_token -> Nullable<Text>, refresh_token -> Text, twofactor_remember -> Nullable<Text>,