فهرست منبع

feat: Push Notifications

Co-authored-by: samb-devel <[email protected]>
Co-authored-by: Zoruk <[email protected]>
GeekCornerGH 2 سال پیش
والد
کامیت
2d66292350

+ 7 - 0
.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.

+ 0 - 0
migrations/mysql/2023-02-18-125735_push_uuid_table/down.sql


+ 1 - 0
migrations/mysql/2023-02-18-125735_push_uuid_table/up.sql

@@ -0,0 +1 @@
+ALTER TABLE devices ADD COLUMN push_uuid TEXT;

+ 0 - 0
migrations/postgresql/2023-02-18-125735_push_uuid_table/down.sql


+ 1 - 0
migrations/postgresql/2023-02-18-125735_push_uuid_table/up.sql

@@ -0,0 +1 @@
+ALTER TABLE devices ADD COLUMN push_uuid TEXT;

+ 0 - 0
migrations/sqlite/2023-02-18-125735_push_uuid_table/down.sql


+ 1 - 0
migrations/sqlite/2023-02-18-125735_push_uuid_table/up.sql

@@ -0,0 +1 @@
+ALTER TABLE devices ADD COLUMN push_uuid TEXT;

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

+ 72 - 6
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
+}

+ 21 - 5
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,

+ 3 - 3
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(())
 }

+ 0 - 33
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 {

+ 1 - 1
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,

+ 16 - 8
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()))
 }

+ 5 - 0
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,

+ 37 - 6
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,

+ 280 - 0
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(&params).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);
+    };
+}

+ 21 - 0
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())

+ 42 - 1
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()
+        }}
+    }
 }

+ 1 - 0
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>,

+ 1 - 0
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>,

+ 1 - 0
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>,