From 4aceff412cbccb94d2de091c30c9afdc1966d36a Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Tue, 4 Oct 2022 22:41:34 +0200 Subject: [PATCH] automatically purge stale invitations adds two configuration options to configure the purging of expired invitations and sets the JWT token expiration date accordingly. when mail is disabled no invitations will be deleted. --- .env.template | 7 +++++++ src/api/core/accounts.rs | 11 ++++++++++- src/api/core/mod.rs | 1 + src/api/mod.rs | 1 + src/auth.rs | 18 ++++++++++++++++-- src/config.rs | 5 +++++ src/db/models/organization.rs | 9 +++++++++ src/db/models/user.rs | 33 +++++++++++++++++++++++++++++++++ src/main.rs | 7 +++++++ 9 files changed, 89 insertions(+), 3 deletions(-) diff --git a/.env.template b/.env.template index 66a04343..7e5f1ba0 100644 --- a/.env.template +++ b/.env.template @@ -97,6 +97,10 @@ ## 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 checks for stale invitations past their expiration date. +## Defaults to hourly (3rd minute of every hour). Set blank to disable this job. +# INVITATION_PURGE_SCHEDULE="0 3 * * * *" +## ## Cron schedule of the job that checks for incomplete 2FA logins. ## Defaults to once every minute. Set blank to disable this job. # INCOMPLETE_2FA_SCHEDULE="30 * * * * *" @@ -245,6 +249,9 @@ ## Name shown in the invitation emails that don't come from a specific organization # INVITATION_ORG_NAME=Vaultwarden +## Specify the number of hours after which an Organization Invite will automatically expire (0 means never) +# INVITATION_EXPIRATION_HOURS=0 + ## Per-organization attachment storage limit (KB) ## Max kilobytes of attachment storage allowed per organization. ## When this limit is reached, organization members will not be allowed to upload further attachments for ciphers owned by that organization. diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 35202698..7ba126d4 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -6,7 +6,7 @@ use crate::{ api::{EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType}, auth::{decode_delete, decode_invite, decode_verify_email, Headers}, crypto, - db::{models::*, DbConn}, + db::{models::*, DbConn, DbPool}, mail, CONFIG, }; @@ -732,3 +732,12 @@ async fn api_key(data: JsonUpcase, headers: Headers, async fn rotate_api_key(data: JsonUpcase, headers: Headers, conn: DbConn) -> JsonResult { _api_key(data, true, headers, conn).await } + +pub async fn purge_stale_invitations(pool: DbPool) { + debug!("Purging stale invitations"); + if let Ok(conn) = pool.get().await { + User::purge_stale_invitations(&conn).await; + } else { + error!("Failed to get DB connection while purging stale invitations") + } +} diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 3f5da0eb..bfa3e6a4 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -6,6 +6,7 @@ mod organizations; mod sends; pub mod two_factor; +pub use accounts::purge_stale_invitations; pub use ciphers::purge_trashed_ciphers; pub use ciphers::{CipherSyncData, CipherSyncType}; pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; diff --git a/src/api/mod.rs b/src/api/mod.rs index b9e9f38c..29ac9573 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -12,6 +12,7 @@ pub use crate::api::{ admin::routes as admin_routes, core::catchers as core_catchers, core::purge_sends, + core::purge_stale_invitations, core::purge_trashed_ciphers, core::routes as core_routes, core::two_factor::send_incomplete_2fa_notifications, diff --git a/src/auth.rs b/src/auth.rs index f99fbd39..eed5e4e8 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -69,7 +69,20 @@ pub fn decode_login(token: &str) -> Result { } pub fn decode_invite(token: &str) -> Result { - decode_jwt(token, JWT_INVITE_ISSUER.to_string()) + let mut validation = jsonwebtoken::Validation::new(JWT_ALGORITHM); + let issuer = JWT_INVITE_ISSUER.to_string(); + validation.leeway = 30; // 30 seconds + // Invitations should be valid forever if disabled + if CONFIG.invitation_expiration_hours() == 0 { + validation.validate_exp = false; + } else { + validation.validate_exp = true; + } + validation.validate_nbf = true; + validation.set_issuer(&[issuer]); + + let token = token.replace(char::is_whitespace, ""); + jsonwebtoken::decode(&token, &PUBLIC_RSA_KEY, &validation).map(|d| d.claims).map_res("Error decoding JWT") } pub fn decode_emergency_access_invite(token: &str) -> Result { @@ -148,9 +161,10 @@ pub fn generate_invite_claims( invited_by_email: Option, ) -> InviteJwtClaims { let time_now = Utc::now().naive_utc(); + let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); InviteJwtClaims { nbf: time_now.timestamp(), - exp: (time_now + Duration::days(5)).timestamp(), + exp: (time_now + Duration::hours(expire_hours)).timestamp(), iss: JWT_INVITE_ISSUER.to_string(), sub: uuid, email, diff --git a/src/config.rs b/src/config.rs index b8f3246b..753b2922 100644 --- a/src/config.rs +++ b/src/config.rs @@ -361,6 +361,9 @@ 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(); + /// Purge stale invidations |> Cron schedule of the job that checks for stale invitations + /// Defaults to hourly. Set blank to disable this job. + invitation_purge_schedule: String, false, def, "0 3 * * * *".to_string(); /// Incomplete 2FA login schedule |> Cron schedule of the job that checks for incomplete 2FA logins. /// Defaults to once every minute. Set blank to disable this job. incomplete_2fa_schedule: String, false, def, "30 * * * * *".to_string(); @@ -430,6 +433,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; + /// Invitation auto-expiration time (hours) |> Specify the number of hours after which an Organization Invite will expire (0 means never) + invitation_expiration_hours: u32, false, def, 120; /// Allow emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users. emergency_access_allowed: bool, true, def, true; /// Password iterations |> Number of server-side passwords hashing iterations. diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 99787eb8..0e45bb47 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -481,6 +481,15 @@ impl UserOrganization { Ok(()) } + pub async fn delete_invites_for_user(user_uuid: &str, conn: &DbConn) -> EmptyResult { + for membership in Self::find_by_user(user_uuid, conn).await { + if membership.status == super::UserOrgStatus::Invited as i32 { + membership.delete(conn).await?; + } + } + Ok(()) + } + pub async fn find_by_email_and_org(email: &str, org_id: &str, conn: &DbConn) -> Option { if let Some(user) = super::User::find_by_mail(email, conn).await { if let Some(user_org) = UserOrganization::find_by_user_and_org(&user.uuid, org_id, conn).await { diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 9e692a3f..47965ed8 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -366,6 +366,39 @@ impl User { None => None, } } + + pub async fn find_old_invites(dt: &NaiveDateTime, conn: &DbConn) -> Vec { + db_run! {conn: { + users::table + .filter(users::public_key.is_null()) + .filter(users::updated_at.lt(dt)) + .load::(conn).expect("Error loading invited Users").from_db() + }} + } + + pub async fn purge_stale_invitations(conn: &DbConn) { + // never delete invitations when mail is disabled + if !CONFIG.mail_enabled() { + return; + } + let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); + if expire_hours == 0 { + return; + } + + let now = Utc::now().naive_utc(); + let dt = now - Duration::hours(expire_hours); + + // for each invite check get user + for user in Self::find_old_invites(&dt, conn).await { + // check UserOrgStatus for open invitations of a given user + UserOrganization::delete_invites_for_user(&user.uuid, conn).await.ok(); + + info!("Purge stale invite for {} which expired after {} hours", &user.email, expire_hours); + // remove corresponding User + user.delete(conn).await.ok(); + } + } } impl Invitation { diff --git a/src/main.rs b/src/main.rs index c877c01c..2e7b5561 100644 --- a/src/main.rs +++ b/src/main.rs @@ -478,6 +478,13 @@ async fn schedule_jobs(pool: db::DbPool) { })); } + // Purge stale invitations + if !CONFIG.invitation_purge_schedule().is_empty() { + sched.add(Job::new(CONFIG.invitation_purge_schedule().parse().unwrap(), || { + runtime.spawn(api::purge_stale_invitations(pool.clone())); + })); + } + // Send email notifications about incomplete 2FA logins, which potentially // indicates that a user's master password has been compromised. if !CONFIG.incomplete_2fa_schedule().is_empty() {