Skip to content

Commit

Permalink
automatically purge stale invitations
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
stefan0xC committed Oct 5, 2022
1 parent 6fa6eb1 commit 4aceff4
Show file tree
Hide file tree
Showing 9 changed files with 89 additions and 3 deletions.
7 changes: 7 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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 * * * * *"
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion src/api/core/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -732,3 +732,12 @@ async fn api_key(data: JsonUpcase<SecretVerificationRequest>, headers: Headers,
async fn rotate_api_key(data: JsonUpcase<SecretVerificationRequest>, 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")
}
}
1 change: 1 addition & 0 deletions src/api/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
1 change: 1 addition & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 16 additions & 2 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,20 @@ pub fn decode_login(token: &str) -> Result<LoginJwtClaims, Error> {
}

pub fn decode_invite(token: &str) -> Result<InviteJwtClaims, Error> {
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<EmergencyAccessInviteJwtClaims, Error> {
Expand Down Expand Up @@ -148,9 +161,10 @@ pub fn generate_invite_claims(
invited_by_email: Option<String>,
) -> 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,
Expand Down
5 changes: 5 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions src/db/models/organization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserOrganization> {
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 {
Expand Down
33 changes: 33 additions & 0 deletions src/db/models/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,39 @@ impl User {
None => None,
}
}

pub async fn find_old_invites(dt: &NaiveDateTime, conn: &DbConn) -> Vec<Self> {
db_run! {conn: {
users::table
.filter(users::public_key.is_null())
.filter(users::updated_at.lt(dt))
.load::<UserDb>(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 {
Expand Down
7 changes: 7 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down

0 comments on commit 4aceff4

Please sign in to comment.