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() {