From 77debecdc321666ec922854da99eb7af412e1c18 Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:33:03 +0200 Subject: [PATCH 1/6] Add optional TOTP second factor for the admin page --- .env.template | 8 ++++ src/api/admin.rs | 58 +++++++++++++++++++++++++--- src/config.rs | 9 +++++ src/static/templates/admin/login.hbs | 3 ++ 4 files changed, 73 insertions(+), 5 deletions(-) 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}} From e049caa40e3e2f4afd67eeae5f38df41ba2f29b7 Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:01:39 +0200 Subject: [PATCH 2/6] Add QR code enrollment and management page for admin TOTP --- .env.template | 2 + Cargo.lock | 7 +++ Cargo.toml | 3 + src/api/admin.rs | 95 ++++++++++++++++++++++++++++- src/api/web.rs | 1 + src/config.rs | 43 ++++++++++++- src/static/scripts/admin_totp.js | 63 +++++++++++++++++++ src/static/templates/admin/base.hbs | 3 + src/static/templates/admin/totp.hbs | 59 ++++++++++++++++++ 9 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 src/static/scripts/admin_totp.js create mode 100644 src/static/templates/admin/totp.hbs diff --git a/.env.template b/.env.template index 4efaee07..5d9862e1 100644 --- a/.env.template +++ b/.env.template @@ -433,6 +433,8 @@ ## 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 +## Tip: Instead of setting this variable you can enroll interactively with a +## QR code on the "Admin 2FA" page of the admin panel (/admin/totp). ## Note: This has no effect when DISABLE_ADMIN_TOKEN is enabled. # ADMIN_TOTP_SECRET=JBSWY3DPEHPK3PXP diff --git a/Cargo.lock b/Cargo.lock index 0715098c..a937f4e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4042,6 +4042,12 @@ dependencies = [ "psl-types", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quanta" version = "0.12.6" @@ -5938,6 +5944,7 @@ dependencies = [ "pastey 0.2.3", "percent-encoding", "pico-args", + "qrcode", "rand 0.10.2", "regex", "reqsign-aws-v4", diff --git a/Cargo.toml b/Cargo.toml index 909570e7..d712b397 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -147,6 +147,9 @@ jsonwebtoken = { version = "10.4.0", default-features = false, features = ["rust # TOTP library totp-lite = "2.0.1" +# QR code generation (admin page TOTP enrollment) +qrcode = { version = "0.14.1", default-features = false, features = ["svg"] } + # Yubico Library yubico = { package = "yubico_ng", version = "0.15.0", default-features = false, features = ["online-tokio"] } diff --git a/src/api/admin.rs b/src/api/admin.rs index bb01ee96..bcaf694c 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -74,6 +74,10 @@ pub fn routes() -> Vec { get_diagnostics_config, resend_user_invite, get_diagnostics_http, + totp_page, + totp_generate, + totp_enable, + totp_disable, ] } @@ -256,8 +260,6 @@ fn validate_token(token: &str) -> bool { 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; @@ -265,11 +267,17 @@ fn validate_totp(code: Option<&str>) -> bool { let Some(code) = code.map(str::trim) else { return false; }; + check_totp_code(&secret, code) +} + +fn check_totp_code(secret: &str, code: &str) -> bool { + use totp_lite::{Sha1, totp_custom}; + 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"); + error!("The configured admin TOTP secret is not a valid base32-encoded string"); return false; }; @@ -294,6 +302,87 @@ fn validate_totp(code: Option<&str>) -> bool { false } +#[get("/totp")] +fn totp_page(_token: AdminToken) -> ApiResult> { + let (enabled, source) = CONFIG.admin_totp_status(); + let page_data = json!({ + "enabled": enabled, + "via_env": source == "environment", + }); + let text = AdminTemplateData::new("admin/totp", page_data).render()?; + Ok(Html(text)) +} + +#[post("/totp/generate", format = "application/json")] +fn totp_generate(_token: AdminToken) -> JsonResult { + use qrcode::{QrCode, render::svg}; + + let secret = crate::crypto::encode_random_bytes::<20>(&data_encoding::BASE32); + let uri = format!("otpauth://totp/Vaultwarden%20Admin?secret={secret}&issuer=Vaultwarden%20Admin"); + let Ok(qr) = QrCode::new(uri.as_bytes()) else { + err!("Failed to generate QR code") + }; + let qr_svg = qr.render::>().min_dimensions(240, 240).build(); + + Ok(Json(json!({ + "secret": secret, + "uri": uri, + "qr_svg": qr_svg, + }))) +} + +#[derive(Deserialize)] +struct TotpEnableData { + secret: String, + code: String, +} + +#[post("/totp/enable", format = "application/json", data = "")] +async fn totp_enable(data: Json, _token: AdminToken) -> EmptyResult { + let data = data.into_inner(); + if CONFIG.admin_totp_status().0 { + err!("Admin page TOTP is already enabled") + } + let secret = data.secret.trim().to_uppercase(); + if secret.is_empty() || data_encoding::BASE32.decode(secret.as_bytes()).is_err() { + err!("Invalid TOTP secret") + } + if !check_totp_code(&secret, data.code.trim()) { + err!("Invalid TOTP code, please try again") + } + if let Err(e) = CONFIG.set_admin_totp_secret(Some(secret)).await { + err!(format!("Unable to save the TOTP secret: {e:?}")) + } + Ok(()) +} + +#[derive(Deserialize)] +struct TotpDisableData { + code: String, +} + +#[post("/totp/disable", format = "application/json", data = "")] +async fn totp_disable(data: Json, _token: AdminToken) -> EmptyResult { + let data = data.into_inner(); + let (enabled, source) = CONFIG.admin_totp_status(); + if !enabled { + err!("Admin page TOTP is not enabled") + } + if source == "environment" { + err!("The TOTP secret is set via `ADMIN_TOTP_SECRET` and can only be removed from the environment") + } + let Some(secret) = CONFIG.admin_totp_secret() else { + err!("Admin page TOTP is not enabled") + }; + if !check_totp_code(&secret, data.code.trim()) { + err!("Invalid TOTP code, please try again") + } + if let Err(e) = CONFIG.set_admin_totp_secret(None).await { + err!(format!("Unable to remove the TOTP secret: {e:?}")) + } + Ok(()) +} + #[derive(Serialize)] struct AdminTemplateData { page_content: String, diff --git a/src/api/web.rs b/src/api/web.rs index 5bd4c85d..77bffe87 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -256,6 +256,7 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro "admin.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/admin.css"))), "admin.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin.js"))), "admin_settings.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_settings.js"))), + "admin_totp.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_totp.js"))), "admin_users.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_users.js"))), "admin_organizations.js" => { Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_organizations.js"))) diff --git a/src/config.rs b/src/config.rs index b3a50829..a1a508ad 100644 --- a/src/config.rs +++ b/src/config.rs @@ -274,6 +274,15 @@ macro_rules! make_config { )+)+ } + /// Clears all editable values, leaving only the non-editable ones (inverse of `clear_non_editable`). + fn clear_editable(&mut self) { + $($( + if $editable { + self.$name = None; + } + )+)+ + } + /// Merges the values of both builders into a new builder. /// If both have the same element, `other` wins. fn merge(&self, other: &Self, show_overrides: bool, overrides: &mut Vec<&str>) -> Self { @@ -652,8 +661,8 @@ 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; + /// Admin TOTP secret |> Base32-encoded secret. When set, logging in to the admin page additionally requires a time-based one-time code. Manage it via the Admin 2FA page. Has no effect when DISABLE_ADMIN_TOKEN is enabled! + admin_totp_secret: Pass, false, 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(); @@ -1459,9 +1468,15 @@ impl Config { // TODO: Remove values that are defaults, above only checks those set by env and not the defaults let mut builder = other; - // Remove values that are not editable + // Remove values that are not editable, but keep previously saved non-editable values + // (e.g. `_duo_akey` or an enrolled `admin_totp_secret`). Those are not part of the + // posted settings form and would otherwise be lost on every settings save. if ignore_non_editable { builder.clear_non_editable(); + let mut preserved = self.inner.read().unwrap()._usr.clone(); + preserved.clear_editable(); + let mut ignored_overrides = Vec::new(); + builder = preserved.merge(&builder, false, &mut ignored_overrides); } // Serialize now before we consume the builder @@ -1597,6 +1612,27 @@ impl Config { } } + /// Persists (or removes, on `None`) the admin page TOTP secret in the saved config file. + pub async fn set_admin_totp_secret(&self, secret: Option) -> Result<(), Error> { + let mut builder = self.inner.read().unwrap()._usr.clone(); + builder.admin_totp_secret = secret; + self.update_config(builder, false).await + } + + /// Returns whether admin page TOTP is enabled, and whether the secret was enrolled + /// via the admin page ("enrolled") or comes from the environment ("environment"). + pub fn admin_totp_status(&self) -> (bool, &'static str) { + let inner = self.inner.read().unwrap(); + let is_set = |v: &Option| v.as_deref().is_some_and(|s| !s.trim().is_empty()); + if is_set(&inner._usr.admin_totp_secret) { + (true, "enrolled") + } else if is_set(&inner._env.admin_totp_secret) { + (true, "environment") + } else { + (false, "none") + } + } + pub fn is_webauthn_2fa_supported(&self) -> bool { Url::parse(&self.domain()).expect("DOMAIN not a valid URL").domain().is_some() } @@ -1746,6 +1782,7 @@ where reg!("admin/users"); reg!("admin/organizations"); reg!("admin/diagnostics"); + reg!("admin/totp"); reg!("404"); diff --git a/src/static/scripts/admin_totp.js b/src/static/scripts/admin_totp.js new file mode 100644 index 00000000..f80672de --- /dev/null +++ b/src/static/scripts/admin_totp.js @@ -0,0 +1,63 @@ +"use strict"; +/* eslint-env es2017, browser */ +/* global BASE_URL:readable, _post:readable */ + +function totpGenerate() { + fetch(`${BASE_URL}/totp/generate`, { + method: "POST", + mode: "same-origin", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + }).then(resp => { + if (!resp.ok) { + return resp.text().then(body => Promise.reject(body)); + } + return resp.json(); + }).then(data => { + const enroll = document.getElementById("totp-enroll"); + enroll.dataset.secret = data.secret; + document.getElementById("totp-qr").innerHTML = data.qr_svg; + document.getElementById("totp-secret").textContent = data.secret; + enroll.classList.remove("d-none"); + document.getElementById("totp-generate").textContent = "Generate a new secret"; + document.getElementById("totp-enable-code").focus(); + }).catch(e => { + alert(`Failed to generate a TOTP secret\n${e}`); + }); +} + +function totpEnable() { + const secret = document.getElementById("totp-enroll").dataset.secret; + const code = document.getElementById("totp-enable-code").value.trim(); + if (!code) { + alert("Please enter the 6-digit code from your authenticator app"); + return; + } + _post(`${BASE_URL}/totp/enable`, + "2FA for the admin page is now enabled", + "Failed to enable 2FA", + JSON.stringify({ secret, code }) + ); +} + +function totpDisable() { + const code = document.getElementById("totp-disable-code").value.trim(); + if (!code) { + alert("Please enter the current 6-digit code from your authenticator app"); + return; + } + if (!confirm("Really disable two-factor authentication for the admin page?")) { + return; + } + _post(`${BASE_URL}/totp/disable`, + "2FA for the admin page is now disabled", + "Failed to disable 2FA", + JSON.stringify({ code }) + ); +} + +document.addEventListener("DOMContentLoaded", (/*event*/) => { + document.getElementById("totp-generate")?.addEventListener("click", totpGenerate); + document.getElementById("totp-enable")?.addEventListener("click", totpEnable); + document.getElementById("totp-disable")?.addEventListener("click", totpDisable); +}); diff --git a/src/static/templates/admin/base.hbs b/src/static/templates/admin/base.hbs index e1dcacb5..aa63fd2a 100644 --- a/src/static/templates/admin/base.hbs +++ b/src/static/templates/admin/base.hbs @@ -48,6 +48,9 @@ + {{/if}} - {{/if}}