diff --git a/.env.template b/.env.template index 0d922774..4efaee07 100644 --- a/.env.template +++ b/.env.template @@ -428,6 +428,14 @@ ## Old plain text string (Will generate warnings in favor of Argon2) # ADMIN_TOKEN=Vy2VyYTTsKPv8W5aEOWUbB/Bt3DEKePbHmI4m9VcemUMS2rEviDowNAFqYi1xjmp +## Optional second factor for the admin page login. +## When set, logging in at /admin additionally requires a 6-digit time-based +## one-time code (TOTP) generated from this Base32-encoded secret. +## Add the same secret to any authenticator app to generate the codes. +## Generate a secret with e.g.: openssl rand 20 | base32 +## Note: This has no effect when DISABLE_ADMIN_TOKEN is enabled. +# ADMIN_TOTP_SECRET=JBSWY3DPEHPK3PXP + ## Enable this to bypass the admin panel security. This option is only ## meant to be used with the use of a separate auth layer in front # DISABLE_ADMIN_TOKEN=false diff --git a/src/api/admin.rs b/src/api/admin.rs index 7037bfb1..bb01ee96 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -1,4 +1,7 @@ -use std::{env, sync::LazyLock}; +use std::{ + env, + sync::{LazyLock, Mutex}, +}; use reqwest::Method; use rocket::{ @@ -167,6 +170,7 @@ fn render_admin_login(msg: Option<&str>, redirect: Option<&str>) -> ApiResult, redirect: Option<&str>) -> ApiResult, redirect: Option, } @@ -198,8 +203,8 @@ fn post_admin_login( ))); } - // If the token is invalid, redirect to login page - if validate_token(&data.token) { + // If the token or the TOTP code is invalid, redirect to login page + if validate_token(&data.token) && validate_totp(data.totp.as_deref()) { // If the token received is valid, generate JWT and save it as a cookie let claims = generate_admin_claims(); let jwt = encode_jwt(&claims); @@ -218,9 +223,9 @@ fn post_admin_login( Err(AdminResponse::Ok(render_admin_page())) } } else { - error!("Invalid admin token. IP: {}", ip.ip); + error!("Invalid admin credentials. IP: {}", ip.ip); Err(AdminResponse::Unauthorized(render_admin_login( - Some("Invalid admin token, please try again."), + Some("Invalid credentials, please try again."), redirect.as_deref(), ))) } @@ -246,6 +251,49 @@ fn validate_token(token: &str) -> bool { } } +// The last accepted TOTP time step, kept in memory since there is no database record for the admin. +// Restarting Vaultwarden resets it, which at worst allows a code from the previous 30 seconds to be reused once. +static ADMIN_TOTP_LAST_USED: Mutex = Mutex::new(0); + +fn validate_totp(code: Option<&str>) -> bool { + use totp_lite::{Sha1, totp_custom}; + + let Some(secret) = CONFIG.admin_totp_secret() else { + // No TOTP secret configured, the admin token alone is sufficient + return true; + }; + let Some(code) = code.map(str::trim) else { + return false; + }; + if code.len() != 6 || !code.chars().all(char::is_numeric) { + return false; + } + let Ok(decoded_secret) = data_encoding::BASE32.decode(secret.trim().to_uppercase().as_bytes()) else { + error!("The configured `ADMIN_TOTP_SECRET` is not a valid base32-encoded string"); + return false; + }; + + let current_timestamp = chrono::Utc::now().timestamp(); + let mut last_used = ADMIN_TOTP_LAST_USED.lock().expect("admin TOTP mutex poisoned"); + + // Allow one step of time drift in either direction, like the user 2FA in `two_factor::authenticator` does + for step in -1..=1i64 { + let time_step = current_timestamp / 30 + step; + let time = (current_timestamp + step * 30).cast_unsigned(); + let generated = totp_custom::(30, 6, &decoded_secret, time); + + if crate::crypto::ct_eq(&generated, code) { + if time_step <= *last_used { + warn!("This admin TOTP code has already been used"); + return false; + } + *last_used = time_step; + return true; + } + } + false +} + #[derive(Serialize)] struct AdminTemplateData { page_content: String, diff --git a/src/config.rs b/src/config.rs index 49281b6c..b3a50829 100644 --- a/src/config.rs +++ b/src/config.rs @@ -652,6 +652,9 @@ make_config! { /// Admin token/Argon2 PHC |> The plain text token or Argon2 PHC string used to authenticate in this very same page. Changing it here will not deauthorize the current session! admin_token: Pass, true, option; + /// Admin TOTP secret |> Base32-encoded secret. When set, logging in to the admin page additionally requires a time-based one-time code. Has no effect when DISABLE_ADMIN_TOKEN is enabled! + admin_totp_secret: Pass, true, option; + /// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization invitation_org_name: String, true, def, "Vaultwarden".to_owned(); @@ -1258,6 +1261,12 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } _ => {} } + + if let Some(ref secret) = cfg.admin_totp_secret + && data_encoding::BASE32.decode(secret.trim().to_uppercase().as_bytes()).is_err() + { + err!("`ADMIN_TOTP_SECRET` is not a valid base32-encoded string") + } } if cfg.increase_note_size_limit { diff --git a/src/static/templates/admin/login.hbs b/src/static/templates/admin/login.hbs index 2d36faba..e9d088da 100644 --- a/src/static/templates/admin/login.hbs +++ b/src/static/templates/admin/login.hbs @@ -14,6 +14,9 @@
+ {{#if totp_enabled}} + + {{/if}} {{#if redirect}} {{/if}}