From 4ab936297186746f00a275182b8e01d54466fc3d Mon Sep 17 00:00:00 2001 From: thelittlefireman Date: Wed, 24 Mar 2021 20:15:55 +0100 Subject: [PATCH] Add Emergency contact feature Signed-off-by: thelittlefireman --- .env.template | 11 + .../down.sql | 1 + .../up.sql | 14 + .../down.sql | 1 + .../up.sql | 14 + .../down.sql | 1 + .../up.sql | 14 + src/api/core/accounts.rs | 7 +- src/api/core/emergency_access.rs | 837 +++++++++++++++++- src/api/core/mod.rs | 1 + src/api/mod.rs | 1 + src/auth.rs | 44 + src/config.rs | 16 + src/db/models/emergency_access.rs | 288 ++++++ src/db/models/mod.rs | 2 + src/db/models/user.rs | 3 +- src/db/schemas/mysql/schema.rs | 19 + src/db/schemas/postgresql/schema.rs | 19 + src/db/schemas/sqlite/schema.rs | 19 + src/mail.rs | 135 ++- src/main.rs | 12 + .../emergency_access_invite_accepted.hbs | 8 + .../emergency_access_invite_accepted.html.hbs | 21 + .../emergency_access_invite_confirmed.hbs | 6 + ...emergency_access_invite_confirmed.html.hbs | 17 + .../emergency_access_recovery_approved.hbs | 4 + ...mergency_access_recovery_approved.html.hbs | 11 + .../emergency_access_recovery_initiated.hbs | 6 + ...ergency_access_recovery_initiated.html.hbs | 16 + .../emergency_access_recovery_rejected.hbs | 4 + ...mergency_access_recovery_rejected.html.hbs | 11 + .../emergency_access_recovery_reminder.hbs | 6 + ...mergency_access_recovery_reminder.html.hbs | 16 + .../emergency_access_recovery_timed_out.hbs | 4 + ...ergency_access_recovery_timed_out.html.hbs | 11 + .../email/send_emergency_access_invite.hbs | 8 + .../send_emergency_access_invite.html.hbs | 24 + 37 files changed, 1619 insertions(+), 13 deletions(-) create mode 100644 migrations/mysql/2021-02-10-174254_create_emergency_access/down.sql create mode 100644 migrations/mysql/2021-02-10-174254_create_emergency_access/up.sql create mode 100644 migrations/postgresql/2021-02-10-174254_create_emergency_access/down.sql create mode 100644 migrations/postgresql/2021-02-10-174254_create_emergency_access/up.sql create mode 100644 migrations/sqlite/2021-02-10-174254_create_emergency_access/down.sql create mode 100644 migrations/sqlite/2021-02-10-174254_create_emergency_access/up.sql create mode 100644 src/db/models/emergency_access.rs create mode 100644 src/static/templates/email/emergency_access_invite_accepted.hbs create mode 100644 src/static/templates/email/emergency_access_invite_accepted.html.hbs create mode 100644 src/static/templates/email/emergency_access_invite_confirmed.hbs create mode 100644 src/static/templates/email/emergency_access_invite_confirmed.html.hbs create mode 100644 src/static/templates/email/emergency_access_recovery_approved.hbs create mode 100644 src/static/templates/email/emergency_access_recovery_approved.html.hbs create mode 100644 src/static/templates/email/emergency_access_recovery_initiated.hbs create mode 100644 src/static/templates/email/emergency_access_recovery_initiated.html.hbs create mode 100644 src/static/templates/email/emergency_access_recovery_rejected.hbs create mode 100644 src/static/templates/email/emergency_access_recovery_rejected.html.hbs create mode 100644 src/static/templates/email/emergency_access_recovery_reminder.hbs create mode 100644 src/static/templates/email/emergency_access_recovery_reminder.html.hbs create mode 100644 src/static/templates/email/emergency_access_recovery_timed_out.hbs create mode 100644 src/static/templates/email/emergency_access_recovery_timed_out.html.hbs create mode 100644 src/static/templates/email/send_emergency_access_invite.hbs create mode 100644 src/static/templates/email/send_emergency_access_invite.html.hbs diff --git a/.env.template b/.env.template index 8c741d83..705c533c 100644 --- a/.env.template +++ b/.env.template @@ -77,6 +77,14 @@ ## Cron schedule of the job that checks for trashed items to delete permanently. ## Defaults to daily (5 minutes after midnight). Set blank to disable this job. # TRASH_PURGE_SCHEDULE="0 5 0 * * *" +## +## Cron schedule of the job that sends expiration reminders to emergency request grantors. +## Defaults to hourly (10 minutes after the hour). Set blank to disable this job. +# EMERGENCY_NOTIFICATION_REMINDER_SCHEDULE="0 10 * * * *" +## +## Cron schedule of the job that checks for expired (i.e granted by timeout) emergency requests. +## Defaults to hourly (15 minutes after the hour). Set blank to disable this job. +# EMERGENCY_REQUEST_TIMEOUT_SCHEDULE="0 15 * * * *" ## Enable extended logging, which shows timestamps and targets in the logs # EXTENDED_LOGGING=true @@ -312,6 +320,9 @@ ## If sending the email fails the login attempt will fail!! # REQUIRE_DEVICE_EMAIL=false +## Emergency access enable. Enable or disable the emergency access feature for all users +# EMERGENCY_ACCESS_ALLOWED=false + ## HIBP Api Key ## HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key # HIBP_API_KEY= diff --git a/migrations/mysql/2021-02-10-174254_create_emergency_access/down.sql b/migrations/mysql/2021-02-10-174254_create_emergency_access/down.sql new file mode 100644 index 00000000..0a5f4d12 --- /dev/null +++ b/migrations/mysql/2021-02-10-174254_create_emergency_access/down.sql @@ -0,0 +1 @@ +DROP TABLE emergency_access; \ No newline at end of file diff --git a/migrations/mysql/2021-02-10-174254_create_emergency_access/up.sql b/migrations/mysql/2021-02-10-174254_create_emergency_access/up.sql new file mode 100644 index 00000000..6ee6ee95 --- /dev/null +++ b/migrations/mysql/2021-02-10-174254_create_emergency_access/up.sql @@ -0,0 +1,14 @@ +CREATE TABLE emergency_access ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + grantor_uuid CHAR(36) REFERENCES users (uuid), + grantee_uuid CHAR(36) REFERENCES users (uuid), + email VARCHAR(255), + key_encrypted TEXT, + atype INTEGER NOT NULL, + status INTEGER NOT NULL, + wait_time_days INTEGER NOT NULL, + recovery_initiated_at DATETIME, + last_notification_at DATETIME, + updated_at DATETIME NOT NULL, + created_at DATETIME NOT NULL +); \ No newline at end of file diff --git a/migrations/postgresql/2021-02-10-174254_create_emergency_access/down.sql b/migrations/postgresql/2021-02-10-174254_create_emergency_access/down.sql new file mode 100644 index 00000000..0a5f4d12 --- /dev/null +++ b/migrations/postgresql/2021-02-10-174254_create_emergency_access/down.sql @@ -0,0 +1 @@ +DROP TABLE emergency_access; \ No newline at end of file diff --git a/migrations/postgresql/2021-02-10-174254_create_emergency_access/up.sql b/migrations/postgresql/2021-02-10-174254_create_emergency_access/up.sql new file mode 100644 index 00000000..f5d4e548 --- /dev/null +++ b/migrations/postgresql/2021-02-10-174254_create_emergency_access/up.sql @@ -0,0 +1,14 @@ +CREATE TABLE emergency_access ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + grantor_uuid CHAR(36) REFERENCES users (uuid), + grantee_uuid CHAR(36) REFERENCES users (uuid), + email VARCHAR(255), + key_encrypted TEXT, + atype INTEGER NOT NULL, + status INTEGER NOT NULL, + wait_time_days INTEGER NOT NULL, + recovery_initiated_at TIMESTAMP, + last_notification_at TIMESTAMP, + updated_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL +); \ No newline at end of file diff --git a/migrations/sqlite/2021-02-10-174254_create_emergency_access/down.sql b/migrations/sqlite/2021-02-10-174254_create_emergency_access/down.sql new file mode 100644 index 00000000..0a5f4d12 --- /dev/null +++ b/migrations/sqlite/2021-02-10-174254_create_emergency_access/down.sql @@ -0,0 +1 @@ +DROP TABLE emergency_access; \ No newline at end of file diff --git a/migrations/sqlite/2021-02-10-174254_create_emergency_access/up.sql b/migrations/sqlite/2021-02-10-174254_create_emergency_access/up.sql new file mode 100644 index 00000000..07e50f3d --- /dev/null +++ b/migrations/sqlite/2021-02-10-174254_create_emergency_access/up.sql @@ -0,0 +1,14 @@ +CREATE TABLE emergency_access ( + uuid TEXT NOT NULL PRIMARY KEY, + grantor_uuid TEXT REFERENCES users (uuid), + grantee_uuid TEXT REFERENCES users (uuid), + email TEXT, + key_encrypted TEXT, + atype INTEGER NOT NULL, + status INTEGER NOT NULL, + wait_time_days INTEGER NOT NULL, + recovery_initiated_at DATETIME, + last_notification_at DATETIME, + updated_at DATETIME NOT NULL, + created_at DATETIME NOT NULL +); \ No newline at end of file diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index ee97e82f..4c49d665 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -89,7 +89,12 @@ fn register(data: JsonUpcase, conn: DbConn) -> EmptyResult { user } else if CONFIG.is_signup_allowed(&email) { - err!("Account with this email already exists") + // check if it's invited by emergency contact + if EmergencyAccess::find_invited_by_grantee_email(&data.Email, &conn).is_some() { + user + } else { + err!("Account with this email already exists") + } } else { err!("Registration not allowed or user already exists") } diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 60ebdf10..6ae0a96a 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -1,24 +1,841 @@ +use chrono::{Duration, Utc}; use rocket::Route; use rocket_contrib::json::Json; +use serde_json::Value; +use std::borrow::Borrow; -use crate::{api::JsonResult, auth::Headers, db::DbConn}; +use crate::{ + api::{EmptyResult, JsonResult, JsonUpcase, NumberOrString}, + auth::{decode_emergency_access_invite, Headers}, + db::{models::*, DbConn, DbPool}, + mail, CONFIG, +}; pub fn routes() -> Vec { - routes![get_contacts,] + routes![ + get_contacts, + get_grantees, + get_emergency_access, + put_emergency_access, + delete_emergency_access, + post_delete_emergency_access, + send_invite, + resend_invite, + accept_invite, + confirm_emergency_access, + initiate_emergency_access, + approve_emergency_access, + reject_emergency_access, + takeover_emergency_access, + password_emergency_access, + view_emergency_access, + policies_emergency_access, + ] } -/// This endpoint is expected to return at least something. -/// If we return an error message that will trigger error toasts for the user. -/// To prevent this we just return an empty json result with no Data. -/// When this feature is going to be implemented it also needs to return this empty Data -/// instead of throwing an error/4XX unless it really is an error. +// region get + #[get("/emergency-access/trusted")] -fn get_contacts(_headers: Headers, _conn: DbConn) -> JsonResult { - debug!("Emergency access is not supported."); +fn get_contacts(headers: Headers, conn: DbConn) -> JsonResult { + check_emergency_access_allowed()?; + + let emergency_access_list = EmergencyAccess::find_all_by_grantor_uuid(&headers.user.uuid, &conn); + + let emergency_access_list_json: Vec = + emergency_access_list.iter().map(|e| e.to_json_grantee_details(&conn)).collect(); Ok(Json(json!({ - "Data": [], + "Data": emergency_access_list_json, "Object": "list", "ContinuationToken": null }))) } + +#[get("/emergency-access/granted")] +fn get_grantees(headers: Headers, conn: DbConn) -> JsonResult { + check_emergency_access_allowed()?; + + let emergency_access_list = EmergencyAccess::find_all_by_grantee_uuid(&headers.user.uuid, &conn); + + let emergency_access_list_json: Vec = + emergency_access_list.iter().map(|e| e.to_json_grantor_details(&conn)).collect(); + + Ok(Json(json!({ + "Data": emergency_access_list_json, + "Object": "list", + "ContinuationToken": null + }))) +} + +#[get("/emergency-access/")] +fn get_emergency_access(emer_id: String, conn: DbConn) -> JsonResult { + check_emergency_access_allowed()?; + + match EmergencyAccess::find_by_uuid(&emer_id, &conn) { + Some(emergency_access) => Ok(Json(emergency_access.to_json_grantee_details(&conn))), + None => err!("Emergency access not valid."), + } +} + +// endregion + +// region put/post + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct EmergencyAccessUpdateData { + Type: NumberOrString, + WaitTimeDays: i32, + KeyEncrypted: Option, +} + +#[put("/emergency-access/", data = "")] +fn put_emergency_access(emer_id: String, data: JsonUpcase, conn: DbConn) -> JsonResult { + post_emergency_access(emer_id, data, conn) +} + +#[post("/emergency-access/", data = "")] +fn post_emergency_access(emer_id: String, data: JsonUpcase, conn: DbConn) -> JsonResult { + check_emergency_access_allowed()?; + + let data: EmergencyAccessUpdateData = data.into_inner().data; + + let mut emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) { + Some(emergency_access) => emergency_access, + None => err!("Emergency access not valid."), + }; + + let new_type = match EmergencyAccessType::from_str(&data.Type.into_string()) { + Some(new_type) => new_type as i32, + None => err!("Invalid emergency access type."), + }; + + emergency_access.atype = new_type; + emergency_access.wait_time_days = data.WaitTimeDays; + emergency_access.key_encrypted = data.KeyEncrypted; + + emergency_access.save(&conn)?; + Ok(Json(emergency_access.to_json())) +} + +// endregion + +// region delete + +#[delete("/emergency-access/")] +fn delete_emergency_access(emer_id: String, headers: Headers, conn: DbConn) -> EmptyResult { + check_emergency_access_allowed()?; + + let grantor_user = headers.user; + + let emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) { + Some(emer) => { + if emer.grantor_uuid != grantor_user.uuid && emer.grantee_uuid != Some(grantor_user.uuid) { + err!("Emergency access not valid.") + } + emer + } + None => err!("Emergency access not valid."), + }; + emergency_access.delete(&conn)?; + Ok(()) +} + +#[post("/emergency-access//delete")] +fn post_delete_emergency_access(emer_id: String, headers: Headers, conn: DbConn) -> EmptyResult { + delete_emergency_access(emer_id, headers, conn) +} + +// endregion + +// region invite + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct EmergencyAccessInviteData { + Email: String, + Type: NumberOrString, + WaitTimeDays: i32, +} + +#[post("/emergency-access/invite", data = "")] +fn send_invite(data: JsonUpcase, headers: Headers, conn: DbConn) -> EmptyResult { + check_emergency_access_allowed()?; + + let data: EmergencyAccessInviteData = data.into_inner().data; + let email = data.Email.to_lowercase(); + let wait_time_days = data.WaitTimeDays; + + let emergency_access_status = EmergencyAccessStatus::Invited as i32; + + let new_type = match EmergencyAccessType::from_str(&data.Type.into_string()) { + Some(new_type) => new_type as i32, + None => err!("Invalid emergency access type."), + }; + + let grantor_user = headers.user; + + // avoid setting yourself as emergency contact + if email == grantor_user.email { + err!("You can not set yourself as an emergency contact.") + } + + let grantee_user = match User::find_by_mail(&email, &conn) { + None => { + if !CONFIG.signups_allowed() { + err!(format!("Grantee user does not exist: {}", email)) + } + + if !CONFIG.is_email_domain_allowed(&email) { + err!("Email domain not eligible for invitations") + } + + if !CONFIG.mail_enabled() { + let invitation = Invitation::new(email.clone()); + invitation.save(&conn)?; + } + + let mut user = User::new(email.clone()); + user.save(&conn)?; + user + } + Some(user) => user, + }; + + if EmergencyAccess::find_by_grantor_uuid_and_grantee_uuid_or_email( + &grantor_user.uuid, + &grantee_user.uuid, + &grantee_user.email, + &conn, + ) + .is_some() + { + err!(format!("Grantee user already invited: {}", email)) + } + + let mut new_emergency_access = EmergencyAccess::new( + grantor_user.uuid.clone(), + Some(grantee_user.email.clone()), + emergency_access_status, + new_type, + wait_time_days, + ); + new_emergency_access.save(&conn)?; + + if CONFIG.mail_enabled() { + mail::send_emergency_access_invite( + &grantee_user.email, + &grantee_user.uuid, + Some(new_emergency_access.uuid), + Some(grantor_user.name.clone()), + Some(grantor_user.email), + )?; + } else { + // Automatically mark user as accepted if no email invites + match User::find_by_mail(&email, &conn) { + Some(user) => { + match accept_invite_process(user.uuid, new_emergency_access.uuid, Some(email), conn.borrow()) { + Ok(v) => (v), + Err(e) => err!(e.to_string()), + } + } + None => err!("Grantee user not found."), + } + } + + Ok(()) +} + +#[post("/emergency-access//reinvite")] +fn resend_invite(emer_id: String, headers: Headers, conn: DbConn) -> EmptyResult { + check_emergency_access_allowed()?; + + let emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; + + if emergency_access.grantor_uuid != headers.user.uuid { + err!("Emergency access not valid."); + } + + if emergency_access.status != EmergencyAccessStatus::Invited as i32 { + err!("The grantee user is already accepted or confirmed to the organization"); + } + + let email = match emergency_access.email.clone() { + Some(email) => email, + None => err!("Email not valid."), + }; + + if !CONFIG.is_email_domain_allowed(&email) { + err!("Email domain not eligible for invitations.") + } + + let grantee_user = match User::find_by_mail(&email, &conn) { + None => err!("Grantee user not found."), + Some(user) => user, + }; + + let grantor_user = headers.user; + + if CONFIG.mail_enabled() { + mail::send_emergency_access_invite( + &email, + &grantor_user.uuid, + Some(emergency_access.uuid), + Some(grantor_user.name.clone()), + Some(grantor_user.email), + )?; + } else { + if Invitation::find_by_mail(&email, &conn).is_none() { + let invitation = Invitation::new(email); + invitation.save(&conn)?; + } + + // Automatically mark user as accepted if no email invites + match accept_invite_process(grantee_user.uuid, emergency_access.uuid, emergency_access.email, conn.borrow()) { + Ok(v) => (v), + Err(e) => err!(e.to_string()), + } + } + + Ok(()) +} + +#[derive(Deserialize)] +#[allow(non_snake_case)] +struct AcceptData { + Token: String, +} + +#[post("/emergency-access//accept", data = "")] +fn accept_invite(emer_id: String, data: JsonUpcase, conn: DbConn) -> EmptyResult { + check_emergency_access_allowed()?; + + let data: AcceptData = data.into_inner().data; + let token = &data.Token; + let claims = decode_emergency_access_invite(token)?; + + let grantee_user = match User::find_by_mail(&claims.email, &conn) { + Some(user) => { + Invitation::take(&claims.email, &conn); + user + } + None => err!("Invited user not found"), + }; + + let emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; + + // get grantor user to send Accepted email + let grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &conn) { + Some(user) => user, + None => err!("Grantor user not found."), + }; + + if (claims.emer_id.is_some() && emer_id == claims.emer_id.unwrap()) + && (claims.grantor_name.is_some() && grantor_user.name == claims.grantor_name.unwrap()) + && (claims.grantor_email.is_some() && grantor_user.email == claims.grantor_email.unwrap()) + { + match accept_invite_process(grantee_user.uuid.clone(), emer_id, Some(grantee_user.email.clone()), &conn) { + Ok(v) => (v), + Err(e) => err!(e.to_string()), + } + + if CONFIG.mail_enabled() { + if !CONFIG.is_email_domain_allowed(&grantor_user.email) { + err!("Email domain not valid.") + } + + mail::send_emergency_access_invite_accepted(&grantor_user.email, &grantee_user.email)?; + } + + Ok(()) + } else { + err!("Emergency access invitation error.") + } +} + +fn accept_invite_process(grantee_uuid: String, emer_id: String, email: Option, conn: &DbConn) -> EmptyResult { + let mut emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, conn) { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; + + let emer_email = emergency_access.email; + if emer_email.is_none() || emer_email != email { + err!("User email does not match invite."); + } + + if emergency_access.status == EmergencyAccessStatus::Accepted as i32 { + err!("Emergency contact already accepted."); + } + + emergency_access.status = EmergencyAccessStatus::Accepted as i32; + emergency_access.grantee_uuid = Some(grantee_uuid); + emergency_access.email = None; + emergency_access.save(conn) +} + +#[derive(Deserialize)] +#[allow(non_snake_case)] +struct ConfirmData { + Key: String, +} + +#[post("/emergency-access//confirm", data = "")] +fn confirm_emergency_access( + emer_id: String, + data: JsonUpcase, + headers: Headers, + conn: DbConn, +) -> JsonResult { + check_emergency_access_allowed()?; + + let confirming_user = headers.user; + let data: ConfirmData = data.into_inner().data; + let key = data.Key; + + let mut emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; + + if emergency_access.status != EmergencyAccessStatus::Accepted as i32 + || emergency_access.grantor_uuid != confirming_user.uuid + { + err!("Emergency access not valid.") + } + + let grantor_user = match User::find_by_uuid(&confirming_user.uuid, &conn) { + Some(user) => user, + None => err!("Grantor user not found."), + }; + + if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() { + let grantee_user = match User::find_by_uuid(grantee_uuid, &conn) { + Some(user) => user, + None => err!("Grantee user not found."), + }; + + emergency_access.status = EmergencyAccessStatus::Confirmed as i32; + emergency_access.key_encrypted = Some(key); + emergency_access.email = None; + + emergency_access.save(&conn)?; + + if CONFIG.mail_enabled() { + if !CONFIG.is_email_domain_allowed(&grantee_user.email) { + err!("Email domain not valid.") + } + + mail::send_emergency_access_invite_confirmed(&grantee_user.email, &grantor_user.name)?; + } + Ok(Json(emergency_access.to_json())) + } else { + err!("Grantee user not found.") + } +} + +// endregion + +// region access emergency access + +#[post("/emergency-access//initiate")] +fn initiate_emergency_access(emer_id: String, headers: Headers, conn: DbConn) -> JsonResult { + check_emergency_access_allowed()?; + + let initiating_user = headers.user; + let mut emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; + + if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 + || emergency_access.grantee_uuid != Some(initiating_user.uuid.clone()) + { + err!("Emergency access not valid.") + } + + let grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &conn) { + Some(user) => user, + None => err!("Grantor user not found."), + }; + + let now = Utc::now().naive_utc(); + emergency_access.status = EmergencyAccessStatus::RecoveryInitiated as i32; + emergency_access.updated_at = now; + emergency_access.recovery_initiated_at = Some(now); + emergency_access.last_notification_at = Some(now); + emergency_access.save(&conn)?; + + if CONFIG.mail_enabled() { + if !CONFIG.is_email_domain_allowed(&grantor_user.email) { + err!("Email domain not valid.") + } + + mail::send_emergency_access_recovery_initiated( + &grantor_user.email, + &initiating_user.name, + emergency_access.get_atype_as_str(), + &emergency_access.wait_time_days.clone().to_string(), + )?; + } + Ok(Json(emergency_access.to_json())) +} + +#[post("/emergency-access//approve")] +fn approve_emergency_access(emer_id: String, headers: Headers, conn: DbConn) -> JsonResult { + check_emergency_access_allowed()?; + + let approving_user = headers.user; + let mut emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; + + if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 + || emergency_access.grantor_uuid != approving_user.uuid + { + err!("Emergency access not valid.") + } + + let grantor_user = match User::find_by_uuid(&approving_user.uuid, &conn) { + Some(user) => user, + None => err!("Grantor user not found."), + }; + + if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() { + let grantee_user = match User::find_by_uuid(grantee_uuid, &conn) { + Some(user) => user, + None => err!("Grantee user not found."), + }; + + emergency_access.status = EmergencyAccessStatus::RecoveryApproved as i32; + emergency_access.save(&conn)?; + + if CONFIG.mail_enabled() { + if !CONFIG.is_email_domain_allowed(&grantee_user.email) { + err!("Email domain not valid.") + } + + mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name)?; + } + Ok(Json(emergency_access.to_json())) + } else { + err!("Grantee user not found.") + } +} + +#[post("/emergency-access//reject")] +fn reject_emergency_access(emer_id: String, headers: Headers, conn: DbConn) -> JsonResult { + check_emergency_access_allowed()?; + + let rejecting_user = headers.user; + let mut emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; + + if (emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 + && emergency_access.status != EmergencyAccessStatus::RecoveryApproved as i32) + || emergency_access.grantor_uuid != rejecting_user.uuid + { + err!("Emergency access not valid.") + } + + let grantor_user = match User::find_by_uuid(&rejecting_user.uuid, &conn) { + Some(user) => user, + None => err!("Grantor user not found."), + }; + + if let Some(grantee_uuid) = emergency_access.grantee_uuid.as_ref() { + let grantee_user = match User::find_by_uuid(grantee_uuid, &conn) { + Some(user) => user, + None => err!("Grantee user not found."), + }; + + emergency_access.status = EmergencyAccessStatus::Confirmed as i32; + emergency_access.key_encrypted = None; + emergency_access.save(&conn)?; + + if CONFIG.mail_enabled() { + if !CONFIG.is_email_domain_allowed(&grantee_user.email) { + err!("Email domain not valid.") + } + + mail::send_emergency_access_recovery_rejected(&grantee_user.email, &grantor_user.name)?; + } + Ok(Json(emergency_access.to_json())) + } else { + err!("Grantee user not found.") + } +} + +// endregion + +// region action + +#[post("/emergency-access//view")] +fn view_emergency_access(emer_id: String, headers: Headers, conn: DbConn) -> JsonResult { + check_emergency_access_allowed()?; + + let requesting_user = headers.user; + let host = headers.host; + let emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; + + if !is_valid_request(&emergency_access, requesting_user.uuid, EmergencyAccessType::View) { + err!("Emergency access not valid.") + } + + let ciphers = Cipher::find_owned_by_user(&emergency_access.grantor_uuid, &conn); + + let ciphers_json: Vec = + ciphers.iter().map(|c| c.to_json(&host, &emergency_access.grantor_uuid, &conn)).collect(); + + Ok(Json(json!({ + "Ciphers": ciphers_json, + "KeyEncrypted": &emergency_access.key_encrypted, + "Object": "emergencyAccessView", + }))) +} + +#[post("/emergency-access//takeover")] +fn takeover_emergency_access(emer_id: String, headers: Headers, conn: DbConn) -> JsonResult { + check_emergency_access_allowed()?; + + let requesting_user = headers.user; + let emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; + + if !is_valid_request(&emergency_access, requesting_user.uuid, EmergencyAccessType::Takeover) { + err!("Emergency access not valid.") + } + + let grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &conn) { + Some(user) => user, + None => err!("Grantor user not found."), + }; + + Ok(Json(json!({ + "Kdf": grantor_user.client_kdf_type, + "KdfIterations": grantor_user.client_kdf_iter, + "KeyEncrypted": &emergency_access.key_encrypted, + "Object": "emergencyAccessTakeover", + }))) +} + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct EmergencyAccessPasswordData { + NewMasterPasswordHash: String, + Key: String, +} + +#[post("/emergency-access//password", data = "")] +fn password_emergency_access( + emer_id: String, + data: JsonUpcase, + headers: Headers, + conn: DbConn, +) -> EmptyResult { + check_emergency_access_allowed()?; + + let data: EmergencyAccessPasswordData = data.into_inner().data; + let new_master_password_hash = &data.NewMasterPasswordHash; + let key = data.Key; + + let requesting_user = headers.user; + let emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; + + if !is_valid_request(&emergency_access, requesting_user.uuid, EmergencyAccessType::Takeover) { + err!("Emergency access not valid.") + } + + let mut grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &conn) { + Some(user) => user, + None => err!("Grantor user not found."), + }; + + // change grantor_user password + grantor_user.set_password(new_master_password_hash, None); + grantor_user.akey = key; + grantor_user.save(&conn)?; + + // Disable TwoFactor providers since they will otherwise block logins + TwoFactor::delete_all_by_user(&grantor_user.uuid, &conn)?; + + // Removing owner, check that there are at least another owner + let user_org_grantor = UserOrganization::find_any_state_by_user(&grantor_user.uuid, &conn); + + // Remove grantor from all organisations unless Owner + for user_org in user_org_grantor { + if user_org.atype != UserOrgType::Owner as i32 { + user_org.delete(&conn)?; + } + } + Ok(()) +} + +// endregion + +#[get("/emergency-access//policies")] +fn policies_emergency_access(emer_id: String, headers: Headers, conn: DbConn) -> JsonResult { + let requesting_user = headers.user; + let emergency_access = match EmergencyAccess::find_by_uuid(&emer_id, &conn) { + Some(emer) => emer, + None => err!("Emergency access not valid."), + }; + + if !is_valid_request(&emergency_access, requesting_user.uuid, EmergencyAccessType::Takeover) { + err!("Emergency access not valid.") + } + + let grantor_user = match User::find_by_uuid(&emergency_access.grantor_uuid, &conn) { + Some(user) => user, + None => err!("Grantor user not found."), + }; + + let policies = OrgPolicy::find_by_user(&grantor_user.uuid, &conn); + let policies_json: Vec = policies.iter().map(OrgPolicy::to_json).collect(); + + Ok(Json(json!({ + "Data": policies_json, + "Object": "list", + "ContinuationToken": null + }))) +} + +fn is_valid_request( + emergency_access: &EmergencyAccess, + requesting_user_uuid: String, + requested_access_type: EmergencyAccessType, +) -> bool { + emergency_access.grantee_uuid == Some(requesting_user_uuid) + && emergency_access.status == EmergencyAccessStatus::RecoveryApproved as i32 + && emergency_access.atype == requested_access_type as i32 +} + +fn check_emergency_access_allowed() -> EmptyResult { + if !CONFIG.emergency_access_allowed() { + err!("Emergency access is not allowed.") + } + Ok(()) +} + +pub fn emergency_request_timeout_job(pool: DbPool) { + debug!("Start emergency_request_timeout_job"); + if !CONFIG.emergency_access_allowed() { + return; + } + + if let Ok(conn) = pool.get() { + let emergency_access_list = EmergencyAccess::find_all_recoveries(&conn); + + if emergency_access_list.is_empty() { + debug!("No emergency request timeout to approve"); + } + + for mut emer in emergency_access_list { + if emer.recovery_initiated_at.is_some() + && Utc::now().naive_utc() + >= emer.recovery_initiated_at.unwrap() + Duration::days(emer.wait_time_days as i64) + { + emer.status = EmergencyAccessStatus::RecoveryApproved as i32; + emer.save(&conn).expect("Cannot save emergency access on job"); + + if CONFIG.mail_enabled() { + // get grantor user to send Accepted email + let grantor_user = User::find_by_uuid(&emer.grantor_uuid, &conn).expect("Grantor user not found."); + + // get grantee user to send Accepted email + let grantee_user = + User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid."), &conn) + .expect("Grantee user not found."); + + if !CONFIG.is_email_domain_allowed(&grantor_user.email) { + error!("Email domain not valid.") + } + + mail::send_emergency_access_recovery_timed_out( + &grantor_user.email, + &grantee_user.name.clone(), + emer.get_atype_as_str(), + ) + .expect("Error on sending email"); + + if !CONFIG.is_email_domain_allowed(&grantee_user.email) { + error!("Email not valid.") + } + + mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name.clone()) + .expect("Error on sending email"); + } + } + } + } else { + error!("Failed to get DB connection while searching emergency request timed out") + } +} + +pub fn emergency_notification_reminder_job(pool: DbPool) { + debug!("Start emergency_notification_reminder_job"); + if !CONFIG.emergency_access_allowed() { + return; + } + + if let Ok(conn) = pool.get() { + let emergency_access_list = EmergencyAccess::find_all_recoveries(&conn); + + if emergency_access_list.is_empty() { + debug!("No emergency request reminder notification to send"); + } + + for mut emer in emergency_access_list { + if (emer.recovery_initiated_at.is_some() + && Utc::now().naive_utc() + >= emer.recovery_initiated_at.unwrap() + Duration::days((emer.wait_time_days as i64) - 1)) + && (emer.last_notification_at.is_none() + || (emer.last_notification_at.is_some() + && Utc::now().naive_utc() >= emer.last_notification_at.unwrap() + Duration::days(1))) + { + emer.save(&conn).expect("Cannot save emergency access on job"); + + if CONFIG.mail_enabled() { + // get grantor user to send Accepted email + let grantor_user = User::find_by_uuid(&emer.grantor_uuid, &conn).expect("Grantor user not found."); + + if !CONFIG.is_email_domain_allowed(&grantor_user.email) { + error!("Email not valid.") + } + + // get grantee user to send Accepted email + let grantee_user = + User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid."), &conn) + .expect("Grantee user not found."); + + mail::send_emergency_access_recovery_reminder( + &grantor_user.email, + &grantee_user.name.clone(), + emer.get_atype_as_str(), + &emer.wait_time_days.to_string(), + ) + .expect("Error on sending email"); + } + } + } + } else { + error!("Failed to get DB connection while searching emergency notification reminder") + } +} diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index ea682963..9f181ed8 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -7,6 +7,7 @@ mod sends; pub mod two_factor; pub use ciphers::purge_trashed_ciphers; +pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; pub use sends::purge_sends; pub fn routes() -> Vec { diff --git a/src/api/mod.rs b/src/api/mod.rs index f16503d2..e7482cdd 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -13,6 +13,7 @@ pub use crate::api::{ core::purge_sends, core::purge_trashed_ciphers, core::routes as core_routes, + core::{emergency_notification_reminder_job, emergency_request_timeout_job}, icons::routes as icons_routes, identity::routes as identity_routes, notifications::routes as notifications_routes, diff --git a/src/auth.rs b/src/auth.rs index 694f6af9..bbcea5c8 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -22,6 +22,8 @@ static JWT_HEADER: Lazy
= Lazy::new(|| Header::new(JWT_ALGORITHM)); pub static JWT_LOGIN_ISSUER: Lazy = Lazy::new(|| format!("{}|login", CONFIG.domain_origin())); static JWT_INVITE_ISSUER: Lazy = Lazy::new(|| format!("{}|invite", CONFIG.domain_origin())); +static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: Lazy = + Lazy::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin())); static JWT_DELETE_ISSUER: Lazy = Lazy::new(|| format!("{}|delete", CONFIG.domain_origin())); static JWT_VERIFYEMAIL_ISSUER: Lazy = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin())); static JWT_ADMIN_ISSUER: Lazy = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin())); @@ -75,6 +77,10 @@ pub fn decode_invite(token: &str) -> Result { decode_jwt(token, JWT_INVITE_ISSUER.to_string()) } +pub fn decode_emergency_access_invite(token: &str) -> Result { + decode_jwt(token, JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string()) +} + pub fn decode_delete(token: &str) -> Result { decode_jwt(token, JWT_DELETE_ISSUER.to_string()) } @@ -159,6 +165,44 @@ pub fn generate_invite_claims( } } +// var token = _dataProtector.Protect($"EmergencyAccessInvite {emergencyAccess.Id} {emergencyAccess.Email} {nowMillis}"); +#[derive(Debug, Serialize, Deserialize)] +pub struct EmergencyAccessInviteJwtClaims { + // Not before + pub nbf: i64, + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + // Subject + pub sub: String, + + pub email: String, + pub emer_id: Option, + pub grantor_name: Option, + pub grantor_email: Option, +} + +pub fn generate_emergency_access_invite_claims( + uuid: String, + email: String, + emer_id: Option, + grantor_name: Option, + grantor_email: Option, +) -> EmergencyAccessInviteJwtClaims { + let time_now = Utc::now().naive_utc(); + EmergencyAccessInviteJwtClaims { + nbf: time_now.timestamp(), + exp: (time_now + Duration::days(5)).timestamp(), + iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(), + sub: uuid, + email, + emer_id, + grantor_name, + grantor_email, + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct BasicJwtClaims { // Not before diff --git a/src/config.rs b/src/config.rs index 6e0c9f65..31a57bb3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -333,6 +333,12 @@ make_config! { /// Trash purge schedule |> Cron schedule of the job that checks for trashed items to delete permanently. /// Defaults to daily. Set blank to disable this job. trash_purge_schedule: String, false, def, "0 5 0 * * *".to_string(); + /// Emergency notification reminder schedule |> Cron schedule of the job that sends expiration reminders to emergency request grantors. + /// Defaults to hourly. Set blank to disable this job. + emergency_notification_reminder_schedule: String, false, def, "0 10 * * * *".to_string(); + /// Emergency request timeout schedule |> Cron schedule of the job that checks for expired (i.e granted by timeout) emergency requests. + /// Defaults to hourly. Set blank to disable this job. + emergency_request_timeout_schedule: String, false, def, "0 15 * * * *".to_string(); }, /// General settings @@ -385,6 +391,8 @@ make_config! { org_creation_users: String, true, def, "".to_string(); /// Allow invitations |> Controls whether users can be invited by organization admins, even when signups are otherwise disabled invitations_allowed: bool, true, def, true; + /// Allow emergency access |> Controls whether users can enable emergency access to their accounts + emergency_access_allowed: bool, true, def, true; /// Password iterations |> Number of server-side passwords hashing iterations. /// The changes only apply when a user changes their password. Not recommended to lower the value password_iterations: i32, true, def, 100_000; @@ -855,11 +863,19 @@ where reg!("email/delete_account", ".html"); reg!("email/invite_accepted", ".html"); reg!("email/invite_confirmed", ".html"); + reg!("email/emergency_access_invite_accepted", ".html"); + reg!("email/emergency_access_invite_confirmed", ".html"); + reg!("email/emergency_access_recovery_approved", ".html"); + reg!("email/emergency_access_recovery_initiated", ".html"); + reg!("email/emergency_access_recovery_rejected", ".html"); + reg!("email/emergency_access_recovery_reminder", ".html"); + reg!("email/emergency_access_recovery_timed_out", ".html"); reg!("email/new_device_logged_in", ".html"); reg!("email/pw_hint_none", ".html"); reg!("email/pw_hint_some", ".html"); reg!("email/send_2fa_removed_from_org", ".html"); reg!("email/send_org_invite", ".html"); + reg!("email/send_emergency_access_invite", ".html"); reg!("email/twofactor_email", ".html"); reg!("email/verify_email", ".html"); reg!("email/welcome", ".html"); diff --git a/src/db/models/emergency_access.rs b/src/db/models/emergency_access.rs new file mode 100644 index 00000000..6e32db4e --- /dev/null +++ b/src/db/models/emergency_access.rs @@ -0,0 +1,288 @@ +use chrono::{NaiveDateTime, Utc}; +use serde_json::Value; + +use super::User; + +db_object! { + #[derive(Debug, Identifiable, Queryable, Insertable, Associations, AsChangeset)] + #[table_name = "emergency_access"] + #[changeset_options(treat_none_as_null="true")] + #[belongs_to(User, foreign_key = "grantor_uuid")] + #[primary_key(uuid)] + pub struct EmergencyAccess { + pub uuid: String, + pub grantor_uuid: String, + pub grantee_uuid: Option, + pub email: Option, + pub key_encrypted: Option, + pub atype: i32, //EmergencyAccessType + pub status: i32, //EmergencyAccessStatus + pub wait_time_days: i32, + pub recovery_initiated_at: Option, + pub last_notification_at: Option, + pub updated_at: NaiveDateTime, + pub created_at: NaiveDateTime, + } +} + +/// Local methods + +impl EmergencyAccess { + pub fn new(grantor_uuid: String, email: Option, status: i32, atype: i32, wait_time_days: i32) -> Self { + Self { + uuid: crate::util::get_uuid(), + grantor_uuid, + grantee_uuid: None, + email, + status, + atype, + wait_time_days, + recovery_initiated_at: None, + created_at: Utc::now().naive_utc(), + updated_at: Utc::now().naive_utc(), + key_encrypted: None, + last_notification_at: None, + } + } + + pub fn get_atype_as_str(&self) -> &'static str { + if self.atype == EmergencyAccessType::View as i32 { + "View" + } else { + "Takeover" + } + } + + pub fn to_json(&self) -> Value { + json!({ + "Id": self.uuid, + "Status": self.status, + "Type": self.atype, + "WaitTimeDays": self.wait_time_days, + "Object": "emergencyAccess", + }) + } + + pub fn to_json_grantor_details(&self, conn: &DbConn) -> Value { + // find grantor + let grantor_user = User::find_by_uuid(&self.grantor_uuid, conn).unwrap(); + json!({ + "Id": self.uuid, + "Status": self.status, + "Type": self.atype, + "WaitTimeDays": self.wait_time_days, + "GrantorId": grantor_user.uuid, + "Email": grantor_user.email, + "Name": grantor_user.name, + "Object": "emergencyAccessGrantorDetails",}) + } + + pub fn to_json_grantee_details(&self, conn: &DbConn) -> Value { + if self.grantee_uuid.is_some() { + let grantee_user = + User::find_by_uuid(&self.grantee_uuid.clone().unwrap(), conn).expect("Grantee user not found."); + + json!({ + "Id": self.uuid, + "Status": self.status, + "Type": self.atype, + "WaitTimeDays": self.wait_time_days, + "GranteeId": grantee_user.uuid, + "Email": grantee_user.email, + "Name": grantee_user.name, + "Object": "emergencyAccessGranteeDetails",}) + } else if self.email.is_some() { + let grantee_user = User::find_by_mail(&self.email.clone().unwrap(), conn).expect("Grantee user not found."); + json!({ + "Id": self.uuid, + "Status": self.status, + "Type": self.atype, + "WaitTimeDays": self.wait_time_days, + "GranteeId": grantee_user.uuid, + "Email": grantee_user.email, + "Name": grantee_user.name, + "Object": "emergencyAccessGranteeDetails",}) + } else { + json!({ + "Id": self.uuid, + "Status": self.status, + "Type": self.atype, + "WaitTimeDays": self.wait_time_days, + "GranteeId": "", + "Email": "", + "Name": "", + "Object": "emergencyAccessGranteeDetails",}) + } + } +} + +#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] +pub enum EmergencyAccessType { + View = 0, + Takeover = 1, +} + +impl EmergencyAccessType { + pub fn from_str(s: &str) -> Option { + match s { + "0" | "View" => Some(EmergencyAccessType::View), + "1" | "Takeover" => Some(EmergencyAccessType::Takeover), + _ => None, + } + } +} + +impl PartialEq for EmergencyAccessType { + fn eq(&self, other: &i32) -> bool { + *other == *self as i32 + } +} + +impl PartialEq for i32 { + fn eq(&self, other: &EmergencyAccessType) -> bool { + *self == *other as i32 + } +} + +pub enum EmergencyAccessStatus { + Invited = 0, + Accepted = 1, + Confirmed = 2, + RecoveryInitiated = 3, + RecoveryApproved = 4, +} + +// region Database methods + +use crate::db::DbConn; + +use crate::api::EmptyResult; +use crate::error::MapResult; + +impl EmergencyAccess { + pub fn save(&mut self, conn: &DbConn) -> EmptyResult { + User::update_uuid_revision(&self.grantor_uuid, conn); + self.updated_at = Utc::now().naive_utc(); + + db_run! { conn: + sqlite, mysql { + match diesel::replace_into(emergency_access::table) + .values(EmergencyAccessDb::to_db(self)) + .execute(conn) + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(emergency_access::table) + .filter(emergency_access::uuid.eq(&self.uuid)) + .set(EmergencyAccessDb::to_db(self)) + .execute(conn) + .map_res("Error updating emergency access") + } + Err(e) => Err(e.into()), + }.map_res("Error saving emergency access") + } + postgresql { + let value = EmergencyAccessDb::to_db(self); + diesel::insert_into(emergency_access::table) + .values(&value) + .on_conflict(emergency_access::uuid) + .do_update() + .set(&value) + .execute(conn) + .map_res("Error saving emergency access") + } + } + } + + pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult { + for user_org in Self::find_all_by_grantor_uuid(user_uuid, conn) { + user_org.delete(conn)?; + } + for user_org in Self::find_all_by_grantee_uuid(user_uuid, conn) { + user_org.delete(conn)?; + } + Ok(()) + } + + pub fn delete(self, conn: &DbConn) -> EmptyResult { + User::update_uuid_revision(&self.grantor_uuid, conn); + + db_run! { conn: { + diesel::delete(emergency_access::table.filter(emergency_access::uuid.eq(self.uuid))) + .execute(conn) + .map_res("Error removing user from organization") + }} + } + + pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option { + db_run! { conn: { + emergency_access::table + .filter(emergency_access::uuid.eq(uuid)) + .first::(conn) + .ok().from_db() + }} + } + + pub fn find_by_grantor_uuid_and_grantee_uuid_or_email( + grantor_uuid: &str, + grantee_uuid: &str, + email: &str, + conn: &DbConn, + ) -> Option { + db_run! { conn: { + emergency_access::table + .filter(emergency_access::grantor_uuid.eq(grantor_uuid)) + .filter(emergency_access::grantee_uuid.eq(grantee_uuid).or(emergency_access::email.eq(email))) + .first::(conn) + .ok().from_db() + }} + } + + pub fn find_all_recoveries(conn: &DbConn) -> Vec { + db_run! { conn: { + emergency_access::table + .filter(emergency_access::status.eq(EmergencyAccessStatus::RecoveryInitiated as i32)) + .load::(conn).expect("Error loading emergency_access").from_db() + + }} + } + + pub fn find_by_uuid_and_grantor_uuid(uuid: &str, grantor_uuid: &str, conn: &DbConn) -> Option { + db_run! { conn: { + emergency_access::table + .filter(emergency_access::uuid.eq(uuid)) + .filter(emergency_access::grantor_uuid.eq(grantor_uuid)) + .first::(conn) + .ok().from_db() + }} + } + + pub fn find_all_by_grantee_uuid(grantee_uuid: &str, conn: &DbConn) -> Vec { + db_run! { conn: { + emergency_access::table + .filter(emergency_access::grantee_uuid.eq(grantee_uuid)) + .load::(conn).expect("Error loading emergency_access").from_db() + }} + } + + pub fn find_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Option { + db_run! { conn: { + emergency_access::table + .filter(emergency_access::email.eq(grantee_email)) + .filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32)) + .first::(conn) + .ok().from_db() + }} + } + + pub fn find_all_by_grantor_uuid(grantor_uuid: &str, conn: &DbConn) -> Vec { + db_run! { conn: { + emergency_access::table + .filter(emergency_access::grantor_uuid.eq(grantor_uuid)) + .load::(conn).expect("Error loading emergency_access").from_db() + }} + } +} + +// endregion diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 2179b7f0..8b4aeebc 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -2,6 +2,7 @@ mod attachment; mod cipher; mod collection; mod device; +mod emergency_access; mod favorite; mod folder; mod org_policy; @@ -14,6 +15,7 @@ pub use self::attachment::Attachment; pub use self::cipher::Cipher; pub use self::collection::{Collection, CollectionCipher, CollectionUser}; pub use self::device::Device; +pub use self::emergency_access::{EmergencyAccess, EmergencyAccessStatus, EmergencyAccessType}; pub use self::favorite::Favorite; pub use self::folder::{Folder, FolderCipher}; pub use self::org_policy::{OrgPolicy, OrgPolicyType}; diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 3c2120e1..d382cad7 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -176,7 +176,7 @@ impl User { } } -use super::{Cipher, Device, Favorite, Folder, Send, TwoFactor, UserOrgType, UserOrganization}; +use super::{Cipher, Device, EmergencyAccess, Favorite, Folder, Send, TwoFactor, UserOrgType, UserOrganization}; use crate::db::DbConn; use crate::api::EmptyResult; @@ -266,6 +266,7 @@ impl User { } Send::delete_all_by_user(&self.uuid, conn)?; + EmergencyAccess::delete_all_by_user(&self.uuid, conn)?; UserOrganization::delete_all_by_user(&self.uuid, conn)?; Cipher::delete_all_by_user(&self.uuid, conn)?; Favorite::delete_all_by_user(&self.uuid, conn)?; diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs index 149d2267..de717702 100644 --- a/src/db/schemas/mysql/schema.rs +++ b/src/db/schemas/mysql/schema.rs @@ -192,6 +192,23 @@ table! { } } +table! { + emergency_access (uuid) { + uuid -> Text, + grantor_uuid -> Text, + grantee_uuid -> Nullable, + email -> Nullable, + key_encrypted -> Nullable, + atype -> Integer, + status -> Integer, + wait_time_days -> Integer, + recovery_initiated_at -> Nullable, + last_notification_at -> Nullable, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + joinable!(attachments -> ciphers (cipher_uuid)); joinable!(ciphers -> organizations (organization_uuid)); joinable!(ciphers -> users (user_uuid)); @@ -210,6 +227,7 @@ joinable!(users_collections -> collections (collection_uuid)); joinable!(users_collections -> users (user_uuid)); joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); +joinable!(emergency_access -> users (grantor_uuid)); allow_tables_to_appear_in_same_query!( attachments, @@ -227,4 +245,5 @@ allow_tables_to_appear_in_same_query!( users, users_collections, users_organizations, + emergency_access, ); diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs index 8feb2eb2..614a4506 100644 --- a/src/db/schemas/postgresql/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -192,6 +192,23 @@ table! { } } +table! { + emergency_access (uuid) { + uuid -> Text, + grantor_uuid -> Text, + grantee_uuid -> Nullable, + email -> Nullable, + key_encrypted -> Nullable, + atype -> Integer, + status -> Integer, + wait_time_days -> Integer, + recovery_initiated_at -> Nullable, + last_notification_at -> Nullable, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + joinable!(attachments -> ciphers (cipher_uuid)); joinable!(ciphers -> organizations (organization_uuid)); joinable!(ciphers -> users (user_uuid)); @@ -210,6 +227,7 @@ joinable!(users_collections -> collections (collection_uuid)); joinable!(users_collections -> users (user_uuid)); joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); +joinable!(emergency_access -> users (grantor_uuid)); allow_tables_to_appear_in_same_query!( attachments, @@ -227,4 +245,5 @@ allow_tables_to_appear_in_same_query!( users, users_collections, users_organizations, + emergency_access, ); diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index 8feb2eb2..614a4506 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -192,6 +192,23 @@ table! { } } +table! { + emergency_access (uuid) { + uuid -> Text, + grantor_uuid -> Text, + grantee_uuid -> Nullable, + email -> Nullable, + key_encrypted -> Nullable, + atype -> Integer, + status -> Integer, + wait_time_days -> Integer, + recovery_initiated_at -> Nullable, + last_notification_at -> Nullable, + updated_at -> Timestamp, + created_at -> Timestamp, + } +} + joinable!(attachments -> ciphers (cipher_uuid)); joinable!(ciphers -> organizations (organization_uuid)); joinable!(ciphers -> users (user_uuid)); @@ -210,6 +227,7 @@ joinable!(users_collections -> collections (collection_uuid)); joinable!(users_collections -> users (user_uuid)); joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); +joinable!(emergency_access -> users (grantor_uuid)); allow_tables_to_appear_in_same_query!( attachments, @@ -227,4 +245,5 @@ allow_tables_to_appear_in_same_query!( users, users_collections, users_organizations, + emergency_access, ); diff --git a/src/mail.rs b/src/mail.rs index 094f5125..f4278bef 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -13,7 +13,10 @@ use lettre::{ use crate::{ api::EmptyResult, - auth::{encode_jwt, generate_delete_claims, generate_invite_claims, generate_verify_email_claims}, + auth::{ + encode_jwt, generate_delete_claims, generate_emergency_access_invite_claims, generate_invite_claims, + generate_verify_email_claims, + }, error::Error, CONFIG, }; @@ -224,6 +227,136 @@ pub fn send_invite( send_email(address, &subject, body_html, body_text) } +pub fn send_emergency_access_invite( + address: &str, + uuid: &str, + emer_id: Option, + grantor_name: Option, + grantor_email: Option, +) -> EmptyResult { + let claims = generate_emergency_access_invite_claims( + uuid.to_string(), + String::from(address), + emer_id.clone(), + grantor_name.clone(), + grantor_email, + ); + + let invite_token = encode_jwt(&claims); + + let (subject, body_html, body_text) = get_text( + "email/send_emergency_access_invite", + json!({ + "url": CONFIG.domain(), + "emer_id": emer_id.unwrap_or_else(|| "_".to_string()), + "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), + "grantor_name": grantor_name, + "token": invite_token, + }), + )?; + + send_email(address, &subject, body_html, body_text) +} + +pub fn send_emergency_access_invite_accepted(address: &str, grantee_email: &str) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/emergency_access_invite_accepted", + json!({ + "url": CONFIG.domain(), + "grantee_email": grantee_email, + }), + )?; + + send_email(address, &subject, body_html, body_text) +} + +pub fn send_emergency_access_invite_confirmed(address: &str, grantor_name: &str) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/emergency_access_invite_confirmed", + json!({ + "url": CONFIG.domain(), + "grantor_name": grantor_name, + }), + )?; + + send_email(address, &subject, body_html, body_text) +} + +pub fn send_emergency_access_recovery_approved(address: &str, grantor_name: &str) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/emergency_access_recovery_approved", + json!({ + "url": CONFIG.domain(), + "grantor_name": grantor_name, + }), + )?; + + send_email(address, &subject, body_html, body_text) +} + +pub fn send_emergency_access_recovery_initiated( + address: &str, + grantee_name: &str, + atype: &str, + wait_time_days: &str, +) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/emergency_access_recovery_initiated", + json!({ + "url": CONFIG.domain(), + "grantee_name": grantee_name, + "atype": atype, + "wait_time_days": wait_time_days, + }), + )?; + + send_email(address, &subject, body_html, body_text) +} + +pub fn send_emergency_access_recovery_reminder( + address: &str, + grantee_name: &str, + atype: &str, + wait_time_days: &str, +) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/emergency_access_recovery_reminder", + json!({ + "url": CONFIG.domain(), + "grantee_name": grantee_name, + "atype": atype, + "wait_time_days": wait_time_days, + }), + )?; + + send_email(address, &subject, body_html, body_text) +} + +pub fn send_emergency_access_recovery_rejected(address: &str, grantor_name: &str) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/emergency_access_recovery_rejected", + json!({ + "url": CONFIG.domain(), + "grantor_name": grantor_name, + }), + )?; + + send_email(address, &subject, body_html, body_text) +} + +pub fn send_emergency_access_recovery_timed_out(address: &str, grantee_name: &str, atype: &str) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/emergency_access_recovery_timed_out", + json!({ + "url": CONFIG.domain(), + "grantee_name": grantee_name, + "atype": atype, + }), + )?; + + send_email(address, &subject, body_html, body_text) +} + pub fn send_invite_accepted(new_user_email: &str, address: &str, org_name: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/invite_accepted", diff --git a/src/main.rs b/src/main.rs index bcf40e4a..eeabdf28 100644 --- a/src/main.rs +++ b/src/main.rs @@ -345,6 +345,18 @@ fn schedule_jobs(pool: db::DbPool) { })); } + if !CONFIG.emergency_request_timeout_schedule().is_empty() { + sched.add(Job::new(CONFIG.emergency_request_timeout_schedule().parse().unwrap(), || { + api::emergency_request_timeout_job(pool.clone()); + })); + } + + if !CONFIG.emergency_notification_reminder_schedule().is_empty() { + sched.add(Job::new(CONFIG.emergency_notification_reminder_schedule().parse().unwrap(), || { + api::emergency_notification_reminder_job(pool.clone()); + })); + } + // Periodically check for jobs to run. We probably won't need any // jobs that run more often than once a minute, so a default poll // interval of 30 seconds should be sufficient. Users who want to diff --git a/src/static/templates/email/emergency_access_invite_accepted.hbs b/src/static/templates/email/emergency_access_invite_accepted.hbs new file mode 100644 index 00000000..9f316680 --- /dev/null +++ b/src/static/templates/email/emergency_access_invite_accepted.hbs @@ -0,0 +1,8 @@ +Emergency contact {{{grantee_email}}} accepted + +This email is to notify you that {{grantee_email}} has accepted your invitation to become an emergency access contact. + +To confirm this user, Log into {{url}} the Bitwarden web vault, go to settings and confirm the user. + +If you do not wish to confirm this user, you can also remove them on the same page. +{{> email/email_footer_text }} diff --git a/src/static/templates/email/emergency_access_invite_accepted.html.hbs b/src/static/templates/email/emergency_access_invite_accepted.html.hbs new file mode 100644 index 00000000..37d30b5d --- /dev/null +++ b/src/static/templates/email/emergency_access_invite_accepted.html.hbs @@ -0,0 +1,21 @@ +Emergency contact {{{grantee_email}}} accepted + +{{> email/email_header }} + + + + + + + + + + +
+ This email is to notify you that {{grantee_email}} has accepted your invitation to become an emergency access contact. +
+ To confirm this user, log into the vaultwarden web vault, go to settings and confirm the user. +
+ If you do not wish to confirm this user, you can also remove them on the same page. +
+{{> email/email_footer }} \ No newline at end of file diff --git a/src/static/templates/email/emergency_access_invite_confirmed.hbs b/src/static/templates/email/emergency_access_invite_confirmed.hbs new file mode 100644 index 00000000..0510cca2 --- /dev/null +++ b/src/static/templates/email/emergency_access_invite_confirmed.hbs @@ -0,0 +1,6 @@ +Emergency contact for {{{grantor_name}}} confirmed + +This email is to notify you that you have been confirmed as an emergency access contact for *{{grantor_name}}* was confirmed. + +You can now initiate emergency access requests from the web vault. Log in {{url}}. +{{> email/email_footer_text }} diff --git a/src/static/templates/email/emergency_access_invite_confirmed.html.hbs b/src/static/templates/email/emergency_access_invite_confirmed.html.hbs new file mode 100644 index 00000000..52a024ad --- /dev/null +++ b/src/static/templates/email/emergency_access_invite_confirmed.html.hbs @@ -0,0 +1,17 @@ +Emergency contact for {{{grantor_name}}} confirmed + +{{> email/email_header }} + + + + + + + +
+ This email is to notify you that you have been confirmed as an emergency access contact for {{grantor_name}} was confirmed. +
+ You can now initiate emergency access requests from the web vault.
+ Log in +
+{{> email/email_footer }} \ No newline at end of file diff --git a/src/static/templates/email/emergency_access_recovery_approved.hbs b/src/static/templates/email/emergency_access_recovery_approved.hbs new file mode 100644 index 00000000..03921e78 --- /dev/null +++ b/src/static/templates/email/emergency_access_recovery_approved.hbs @@ -0,0 +1,4 @@ +Emergency contact request for {{{grantor_name}}} approved + +{{grantor_name}} has approved your emergency request. You may now login {{url}} on the web vault and access their account. +{{> email/email_footer_text }} diff --git a/src/static/templates/email/emergency_access_recovery_approved.html.hbs b/src/static/templates/email/emergency_access_recovery_approved.html.hbs new file mode 100644 index 00000000..e9efdd88 --- /dev/null +++ b/src/static/templates/email/emergency_access_recovery_approved.html.hbs @@ -0,0 +1,11 @@ +Emergency contact for {{{grantor_name}}} approved + +{{> email/email_header }} + + + + +
+ {{grantor_name}} has approved your emergency request. You may now login on the web vault and access their account. +
+{{> email/email_footer }} \ No newline at end of file diff --git a/src/static/templates/email/emergency_access_recovery_initiated.hbs b/src/static/templates/email/emergency_access_recovery_initiated.hbs new file mode 100644 index 00000000..03b004eb --- /dev/null +++ b/src/static/templates/email/emergency_access_recovery_initiated.hbs @@ -0,0 +1,6 @@ +Emergency access request by {{{grantee_name}}} initiated + +{{grantee_name}} has initiated an emergency request to *{{atype}}* your account. You may login on the web vault and manually approve or reject this request. + +If you do nothing, the request will automatically be approved after {{wait_time_days}} day(s). +{{> email/email_footer_text }} diff --git a/src/static/templates/email/emergency_access_recovery_initiated.html.hbs b/src/static/templates/email/emergency_access_recovery_initiated.html.hbs new file mode 100644 index 00000000..abc9d9c0 --- /dev/null +++ b/src/static/templates/email/emergency_access_recovery_initiated.html.hbs @@ -0,0 +1,16 @@ +Emergency access request by {{{grantee_name}}} initiated + +{{> email/email_header }} + + + + + + + +
+ {{grantee_name}} has initiated an emergency request to {{atype}} your account. You may login on the web vault and manually approve or reject this request. +
+ If you do nothing, the request will automatically be approved after {{wait_time_days}} day(s). +
+{{> email/email_footer }} \ No newline at end of file diff --git a/src/static/templates/email/emergency_access_recovery_rejected.hbs b/src/static/templates/email/emergency_access_recovery_rejected.hbs new file mode 100644 index 00000000..db886fe2 --- /dev/null +++ b/src/static/templates/email/emergency_access_recovery_rejected.hbs @@ -0,0 +1,4 @@ +Emergency access request to {{{grantor_name}}} rejected + +{{grantor_name}} has rejected your emergency request. +{{> email/email_footer_text }} diff --git a/src/static/templates/email/emergency_access_recovery_rejected.html.hbs b/src/static/templates/email/emergency_access_recovery_rejected.html.hbs new file mode 100644 index 00000000..35f71b3c --- /dev/null +++ b/src/static/templates/email/emergency_access_recovery_rejected.html.hbs @@ -0,0 +1,11 @@ +Emergency access request to {{{grantor_name}}} rejected + +{{> email/email_header }} + + + + +
+ {{grantor_name}} has rejected your emergency request. +
+{{> email/email_footer }} \ No newline at end of file diff --git a/src/static/templates/email/emergency_access_recovery_reminder.hbs b/src/static/templates/email/emergency_access_recovery_reminder.hbs new file mode 100644 index 00000000..89b805d3 --- /dev/null +++ b/src/static/templates/email/emergency_access_recovery_reminder.hbs @@ -0,0 +1,6 @@ +Emergency access request by {{{grantee_name}}} is pending + +{{grantee_name}} has a pending emergency request to *{{atype}}* your account. You may login on the web vault and manually approve or reject this request. + +If you do nothing, the request will automatically be approved after {{wait_time_days}} day(s). +{{> email/email_footer_text }} diff --git a/src/static/templates/email/emergency_access_recovery_reminder.html.hbs b/src/static/templates/email/emergency_access_recovery_reminder.html.hbs new file mode 100644 index 00000000..08cbeb59 --- /dev/null +++ b/src/static/templates/email/emergency_access_recovery_reminder.html.hbs @@ -0,0 +1,16 @@ +Emergency access request by {{{grantee_name}}} is pending + +{{> email/email_header }} + + + + + + + +
+ {{grantee_name}} has a pending emergency request to {{atype}} your account. You may login on the web vault and manually approve or reject this request. +
+ If you do nothing, the request will automatically be approved after {{wait_time_days}} day(s). +
+{{> email/email_footer }} \ No newline at end of file diff --git a/src/static/templates/email/emergency_access_recovery_timed_out.hbs b/src/static/templates/email/emergency_access_recovery_timed_out.hbs new file mode 100644 index 00000000..510ea376 --- /dev/null +++ b/src/static/templates/email/emergency_access_recovery_timed_out.hbs @@ -0,0 +1,4 @@ +Emergency access request by {{{grantee_name}}} granted + +{{grantee_name}} has been granted emergency request to *{{atype}}* your account. You may login on the web vault and manually revoke this request. +{{> email/email_footer_text }} diff --git a/src/static/templates/email/emergency_access_recovery_timed_out.html.hbs b/src/static/templates/email/emergency_access_recovery_timed_out.html.hbs new file mode 100644 index 00000000..612bbd5a --- /dev/null +++ b/src/static/templates/email/emergency_access_recovery_timed_out.html.hbs @@ -0,0 +1,11 @@ +Emergency access request by {{{grantee_name}}} granted + +{{> email/email_header }} + + + + +
+ {{grantee_name}} has been granted emergency request to {{atype}} your account. You may login on the web vault and manually revoke this request. +
+{{> email/email_footer }} \ No newline at end of file diff --git a/src/static/templates/email/send_emergency_access_invite.hbs b/src/static/templates/email/send_emergency_access_invite.hbs new file mode 100644 index 00000000..9dc114c0 --- /dev/null +++ b/src/static/templates/email/send_emergency_access_invite.hbs @@ -0,0 +1,8 @@ +Emergency access for {{{grantor_name}}} + +You have been invited to become an emergency contact for {{grantor_name}}. To accept this invite, click the following link: + +Click here to join: {{url}}/#/accept-emergency/?id={{emer_id}}&name={{grantor_name}}&email={{email}}&token={{token}} + +If you do not wish to become an emergency contact for {{grantor_name}}, you can safely ignore this email. +{{> email/email_footer_text }} diff --git a/src/static/templates/email/send_emergency_access_invite.html.hbs b/src/static/templates/email/send_emergency_access_invite.html.hbs new file mode 100644 index 00000000..fd1c0400 --- /dev/null +++ b/src/static/templates/email/send_emergency_access_invite.html.hbs @@ -0,0 +1,24 @@ +Emergency access for {{{grantor_name}}} + +{{> email/email_header }} + + + + + + + + + + +
+ You have been invited to become an emergency contact for {{grantor_name}}. +
+ + Become emergency contact + +
+ If you do not wish to become an emergency contact for {{grantor_name}}, you can safely ignore this email. +
+{{> email/email_footer }} \ No newline at end of file