diff --git a/.env.template b/.env.template index 9fc29989..95349126 100644 --- a/.env.template +++ b/.env.template @@ -610,6 +610,9 @@ ## Note that the checkbox would still be present, but ignored. # DISABLE_2FA_REMEMBER=false ## +## Number of days before a remembered 2FA login expires (min: 1, max: 90). +# TWO_FACTOR_REMEMBER_DAYS=30 +## ## Authenticator Settings ## Disable authenticator time drifted codes to be valid. ## TOTP codes of the previous and next 30 seconds will be invalid diff --git a/src/auth.rs b/src/auth.rs index 762088e5..0aa5978d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -474,13 +474,34 @@ pub fn generate_2fa_remember_claims(device_uuid: DeviceId, user_uuid: UserId) -> let time_now = Utc::now(); TwoFactorRememberClaims { nbf: time_now.timestamp(), - exp: (time_now + TimeDelta::try_days(30).unwrap()).timestamp(), + exp: two_factor_remember_expiration(time_now, CONFIG.two_factor_remember_days()), iss: JWT_2FA_REMEMBER_ISSUER.to_string(), sub: device_uuid, user_uuid, } } +fn two_factor_remember_expiration(time_now: DateTime, remember_days: i64) -> i64 { + (time_now + TimeDelta::try_days(remember_days).expect("TWO_FACTOR_REMEMBER_DAYS is validated")).timestamp() +} + +#[cfg(test)] +mod two_factor_remember_tests { + use chrono::TimeZone; + + use super::*; + + #[test] + fn expiration_uses_configured_number_of_days() { + let time_now = Utc.with_ymd_and_hms(2026, 7, 14, 12, 0, 0).unwrap(); + + assert_eq!( + two_factor_remember_expiration(time_now, 45), + Utc.with_ymd_and_hms(2026, 8, 28, 12, 0, 0).unwrap().timestamp() + ); + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct BasicJwtClaims { // Not before diff --git a/src/config.rs b/src/config.rs index d5b50146..badf19cc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -716,6 +716,8 @@ make_config! { /// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time. /// Note that the checkbox would still be present, but ignored. disable_2fa_remember: bool, true, def, false; + /// Two-Factor remember duration |> Number of days before a remembered Two-Factor login expires (min: 1, max: 90). + two_factor_remember_days: i64, true, def, 30; /// Disable authenticator time drifted codes to be valid |> Enabling this only allows the current TOTP code to be valid /// TOTP codes of the previous and next 30 seconds will be invalid. @@ -1237,6 +1239,10 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { err!("`INVITATION_EXPIRATION_HOURS` has a minimum duration of 1 hour") } + if !(1..=90).contains(&cfg.two_factor_remember_days) { + err!("`TWO_FACTOR_REMEMBER_DAYS` must be between 1 and 90 days") + } + // Validate schedule crontab format if !cfg.send_purge_schedule.is_empty() && cfg.send_purge_schedule.parse::().is_err() { err!("`SEND_PURGE_SCHEDULE` is not a valid cron expression") @@ -1858,3 +1864,25 @@ handlebars::handlebars_helper!(webver: | web_vault_version: String | handlebars::handlebars_helper!(vwver: | vw_version: String | semver::VersionReq::parse(&vw_version).expect("Invalid Vaultwarden version compare string").matches(&VW_VERSION) ); + +#[cfg(test)] +mod tests { + use super::*; + + fn config_with_two_factor_remember_days(days: i64) -> ConfigItems { + ConfigBuilder { + database_url: Some("sqlite://:memory:".to_owned()), + two_factor_remember_days: Some(days), + ..Default::default() + } + .build() + } + + #[test] + fn two_factor_remember_days_are_limited_to_ninety() { + assert!(validate_config(&config_with_two_factor_remember_days(90), false).is_ok()); + + let error = validate_config(&config_with_two_factor_remember_days(91), false).unwrap_err(); + assert_eq!(format!("{error:?}"), "`TWO_FACTOR_REMEMBER_DAYS` must be between 1 and 90 days"); + } +}