Browse Source

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.
pull/2795/head
Stefan Melmuk 3 years ago
parent
commit
4aceff412c
No known key found for this signature in database GPG Key ID: 817020C608FE9C09
  1. 7
      .env.template
  2. 11
      src/api/core/accounts.rs
  3. 1
      src/api/core/mod.rs
  4. 1
      src/api/mod.rs
  5. 18
      src/auth.rs
  6. 5
      src/config.rs
  7. 9
      src/db/models/organization.rs
  8. 33
      src/db/models/user.rs
  9. 7
      src/main.rs

7
.env.template

@ -97,6 +97,10 @@
## Defaults to daily (5 minutes after midnight). Set blank to disable this job. ## Defaults to daily (5 minutes after midnight). Set blank to disable this job.
# TRASH_PURGE_SCHEDULE="0 5 0 * * *" # 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. ## Cron schedule of the job that checks for incomplete 2FA logins.
## Defaults to once every minute. Set blank to disable this job. ## Defaults to once every minute. Set blank to disable this job.
# INCOMPLETE_2FA_SCHEDULE="30 * * * * *" # INCOMPLETE_2FA_SCHEDULE="30 * * * * *"
@ -245,6 +249,9 @@
## Name shown in the invitation emails that don't come from a specific organization ## Name shown in the invitation emails that don't come from a specific organization
# INVITATION_ORG_NAME=Vaultwarden # 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) ## Per-organization attachment storage limit (KB)
## Max kilobytes of attachment storage allowed per organization. ## 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. ## When this limit is reached, organization members will not be allowed to upload further attachments for ciphers owned by that organization.

11
src/api/core/accounts.rs

@ -6,7 +6,7 @@ use crate::{
api::{EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType}, api::{EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType},
auth::{decode_delete, decode_invite, decode_verify_email, Headers}, auth::{decode_delete, decode_invite, decode_verify_email, Headers},
crypto, crypto,
db::{models::*, DbConn}, db::{models::*, DbConn, DbPool},
mail, CONFIG, mail, CONFIG,
}; };
@ -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 { async fn rotate_api_key(data: JsonUpcase<SecretVerificationRequest>, headers: Headers, conn: DbConn) -> JsonResult {
_api_key(data, true, headers, conn).await _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
src/api/core/mod.rs

@ -6,6 +6,7 @@ mod organizations;
mod sends; mod sends;
pub mod two_factor; pub mod two_factor;
pub use accounts::purge_stale_invitations;
pub use ciphers::purge_trashed_ciphers; pub use ciphers::purge_trashed_ciphers;
pub use ciphers::{CipherSyncData, CipherSyncType}; pub use ciphers::{CipherSyncData, CipherSyncType};
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};

1
src/api/mod.rs

@ -12,6 +12,7 @@ pub use crate::api::{
admin::routes as admin_routes, admin::routes as admin_routes,
core::catchers as core_catchers, core::catchers as core_catchers,
core::purge_sends, core::purge_sends,
core::purge_stale_invitations,
core::purge_trashed_ciphers, core::purge_trashed_ciphers,
core::routes as core_routes, core::routes as core_routes,
core::two_factor::send_incomplete_2fa_notifications, core::two_factor::send_incomplete_2fa_notifications,

18
src/auth.rs

@ -69,7 +69,20 @@ pub fn decode_login(token: &str) -> Result<LoginJwtClaims, Error> {
} }
pub fn decode_invite(token: &str) -> Result<InviteJwtClaims, 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> { pub fn decode_emergency_access_invite(token: &str) -> Result<EmergencyAccessInviteJwtClaims, Error> {
@ -148,9 +161,10 @@ pub fn generate_invite_claims(
invited_by_email: Option<String>, invited_by_email: Option<String>,
) -> InviteJwtClaims { ) -> InviteJwtClaims {
let time_now = Utc::now().naive_utc(); let time_now = Utc::now().naive_utc();
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
InviteJwtClaims { InviteJwtClaims {
nbf: time_now.timestamp(), 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(), iss: JWT_INVITE_ISSUER.to_string(),
sub: uuid, sub: uuid,
email, email,

5
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. /// 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. /// Defaults to daily. Set blank to disable this job.
trash_purge_schedule: String, false, def, "0 5 0 * * *".to_string(); 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. /// 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. /// Defaults to once every minute. Set blank to disable this job.
incomplete_2fa_schedule: String, false, def, "30 * * * * *".to_string(); incomplete_2fa_schedule: String, false, def, "30 * * * * *".to_string();
@ -430,6 +433,8 @@ make_config! {
org_creation_users: String, true, def, "".to_string(); 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 /// Allow invitations |> Controls whether users can be invited by organization admins, even when signups are otherwise disabled
invitations_allowed: bool, true, def, true; 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. /// 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; emergency_access_allowed: bool, true, def, true;
/// Password iterations |> Number of server-side passwords hashing iterations. /// Password iterations |> Number of server-side passwords hashing iterations.

9
src/db/models/organization.rs

@ -481,6 +481,15 @@ impl UserOrganization {
Ok(()) 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> { 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) = 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 { if let Some(user_org) = UserOrganization::find_by_user_and_org(&user.uuid, org_id, conn).await {

33
src/db/models/user.rs

@ -366,6 +366,39 @@ impl User {
None => None, 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 { impl Invitation {

7
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 // Send email notifications about incomplete 2FA logins, which potentially
// indicates that a user's master password has been compromised. // indicates that a user's master password has been compromised.
if !CONFIG.incomplete_2fa_schedule().is_empty() { if !CONFIG.incomplete_2fa_schedule().is_empty() {

Loading…
Cancel
Save