Browse Source

Simplify admin TOTP configuration flow

pull/7444/head
tom27052006 22 hours ago
parent
commit
7798f40256
  1. 9
      .env.template
  2. 7
      Cargo.lock
  3. 3
      Cargo.toml
  4. 111
      src/api/admin.rs
  5. 17
      src/api/web.rs
  6. 300
      src/config.rs
  7. 155
      src/static/scripts/admin_settings.js
  8. 63
      src/static/scripts/admin_totp.js
  9. 21
      src/static/scripts/qrcode-generator-2.0.4.LICENSE.txt
  10. 2
      src/static/scripts/qrcode-generator-2.0.4.js
  11. 3
      src/static/templates/admin/base.hbs
  12. 74
      src/static/templates/admin/settings.hbs
  13. 59
      src/static/templates/admin/totp.hbs

9
.env.template

@ -430,13 +430,14 @@
## 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.
## one-time code (TOTP) generated from this Base32-encoded secret (at least 16 bytes).
## 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).
## The admin panel's configuration page can generate a secret and QR code locally
## in the browser, then save the secret to config.json. Once saved, the existing
## secret is never shown there again.
## Note: This has no effect when DISABLE_ADMIN_TOKEN is enabled.
# ADMIN_TOTP_SECRET=JBSWY3DPEHPK3PXP
# ADMIN_TOTP_SECRET=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP
## 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

7
Cargo.lock

@ -4042,12 +4042,6 @@ 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"
@ -5944,7 +5938,6 @@ dependencies = [
"pastey 0.2.3",
"percent-encoding",
"pico-args",
"qrcode",
"rand 0.10.2",
"regex",
"reqsign-aws-v4",

3
Cargo.toml

@ -147,9 +147,6 @@ 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"] }

111
src/api/admin.rs

@ -74,10 +74,6 @@ pub fn routes() -> Vec<Route> {
get_diagnostics_config,
resend_user_invite,
get_diagnostics_http,
totp_page,
totp_generate,
totp_enable,
totp_disable,
]
}
@ -174,7 +170,7 @@ fn render_admin_login(msg: Option<&str>, redirect: Option<&str>) -> ApiResult<Ht
"page_content": "admin/login",
"error": msg,
"redirect": redirect,
"totp_enabled": CONFIG.admin_totp_secret().is_some(),
"totp_enabled": configured_admin_totp_secret().is_some(),
"urlpath": CONFIG.domain_path()
});
@ -255,12 +251,17 @@ fn validate_token(token: &str) -> bool {
}
}
// The last accepted TOTP time step, kept in memory since there is no database record for the admin.
// The active-secret fingerprint and its last accepted TOTP time step, kept in memory since there is no database
// record for the admin. Keeping the fingerprint resets replay protection when the configured secret changes.
// 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<i64> = Mutex::new(0);
static ADMIN_TOTP_LAST_USED: Mutex<Option<(String, i64)>> = Mutex::new(None);
fn configured_admin_totp_secret() -> Option<String> {
CONFIG.admin_totp_secret().filter(|secret| !secret.trim().is_empty())
}
fn validate_totp(code: Option<&str>) -> bool {
let Some(secret) = CONFIG.admin_totp_secret() else {
let Some(secret) = configured_admin_totp_secret() else {
// No TOTP secret configured, the admin token alone is sufficient
return true;
};
@ -273,7 +274,7 @@ fn validate_totp(code: Option<&str>) -> bool {
fn check_totp_code(secret: &str, code: &str) -> bool {
use two_factor::authenticator::{TotpValidation, verify_totp};
if code.len() != 6 || !code.chars().all(char::is_numeric) {
if code.len() != 6 || !code.bytes().all(|digit| digit.is_ascii_digit()) {
return false;
}
let Ok(decoded_secret) = data_encoding::BASE32.decode(secret.trim().to_uppercase().as_bytes()) else {
@ -282,12 +283,17 @@ fn check_totp_code(secret: &str, code: &str) -> bool {
};
let current_timestamp = chrono::Utc::now().timestamp();
let mut last_used = ADMIN_TOTP_LAST_USED.lock().expect("admin TOTP mutex poisoned");
let fingerprint = crate::crypto::sha256_hex(&decoded_secret);
let mut replay_state = ADMIN_TOTP_LAST_USED.lock().expect("admin TOTP mutex poisoned");
let last_used = replay_state
.as_ref()
.filter(|(active_fingerprint, _)| active_fingerprint == &fingerprint)
.map_or(0, |(_, last_used)| *last_used);
// Reuse the shared verifier; allow one step of time drift in either direction, like the user 2FA does.
match verify_totp(&decoded_secret, code, current_timestamp, *last_used, 1) {
match verify_totp(&decoded_secret, code, current_timestamp, last_used, 1) {
TotpValidation::Accepted(time_step) => {
*last_used = time_step;
*replay_state = Some((fingerprint, time_step));
true
}
TotpValidation::Reused => {
@ -298,87 +304,6 @@ fn check_totp_code(secret: &str, code: &str) -> bool {
}
}
#[get("/totp")]
fn totp_page(_token: AdminToken) -> ApiResult<Html<String>> {
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::<svg::Color<'_>>().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 = "<data>")]
async fn totp_enable(data: Json<TotpEnableData>, _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 = "<data>")]
async fn totp_disable(data: Json<TotpDisableData>, _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,

17
src/api/web.rs

@ -256,7 +256,9 @@ 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"))),
"qrcode-generator-2.0.4.js" => {
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/qrcode-generator-2.0.4.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")))
@ -275,3 +277,16 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro
_ => err!(format!("Static file not found: {filename}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn qrcode_generator_is_available_in_production_assets() {
let (content_type, body) = static_files("qrcode-generator-2.0.4.js").unwrap();
assert_eq!(content_type, ContentType::JavaScript);
assert!(body.starts_with(b"/*! qrcode-generator 2.0.4"));
}
}

300
src/config.rs

@ -54,9 +54,11 @@ pub static CONFIG: LazyLock<Config> = LazyLock::new(|| {
});
pub type Pass = String;
pub type AdminTotpSecret = String;
macro_rules! make_config {
// Support string print
( @supportstr $name:ident, $value:expr, AdminTotpSecret, option ) => { serde_json::to_value(&$value.as_ref().map(|_| String::from("***"))).unwrap() };
( @supportstr $name:ident, $value:expr, Pass, option ) => { serde_json::to_value(&$value.as_ref().map(|_| String::from("***"))).unwrap() }; // Optional pass, we map to an Option<String> with "***"
( @supportstr $name:ident, $value:expr, Pass, $none_action:ident ) => { "***".into() }; // Required pass, we return "***"
( @supportstr $name:ident, $value:expr, $ty:ty, option ) => { serde_json::to_value(&$value).unwrap() }; // Optional other or string, we convert to json
@ -67,6 +69,18 @@ macro_rules! make_config {
( @show ) => { "" };
( @show $lit:literal ) => { $lit };
// Write-only values are shown as empty inputs and never serialized to the admin settings page.
( @formvalue $value:expr, AdminTotpSecret ) => { serde_json::Value::Null };
( @formvalue $value:expr, $ty:ident ) => { serde_json::to_value($value).unwrap_or_default() };
( @writeonly AdminTotpSecret ) => { true };
( @writeonly $ty:ident ) => { false };
( @configured $value:expr, AdminTotpSecret ) => {
$value.as_ref().is_some_and(|value| !value.trim().is_empty())
};
( @configured $value:expr, $ty:ident ) => { false };
( @userconfigured $value:expr, AdminTotpSecret ) => { $value.is_some() };
( @userconfigured $value:expr, $ty:ident ) => { false };
// Wrap the optionals in an Option type
( @type $ty:ty, option) => { Option<$ty> };
( @type $ty:ty, $id:ident) => { $ty };
@ -274,15 +288,6 @@ 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 {
@ -345,6 +350,9 @@ macro_rules! make_config {
name: &'static str,
value: serde_json::Value,
default: serde_json::Value,
write_only: bool,
configured: bool,
user_configured: bool,
#[serde(rename = "type")]
r#type: &'static str,
doc: ElementDoc,
@ -371,7 +379,7 @@ macro_rules! make_config {
pub fn prepare_json(&self) -> serde_json::Value {
fn get_form_type(rust_type: &'static str) -> &'static str {
match rust_type {
"Pass" => "password",
"Pass" | "AdminTotpSecret" => "password",
"String" => "text",
"bool" => "checkbox",
_ => "number"
@ -386,10 +394,10 @@ macro_rules! make_config {
}
}
let (def, cfg, overridden) = {
let (def, cfg, usr, overridden) = {
// Lock the inner as short as possible and clone what is needed to prevent deadlocks
let inner = &self.inner.read().unwrap();
(inner._env.build(), inner.config.clone(), inner._overrides.clone())
(inner._env.build(), inner.config.clone(), inner._usr.clone(), inner._overrides.clone())
};
let data: Vec<GroupData> = vec![
@ -404,8 +412,11 @@ macro_rules! make_config {
ElementData {
editable: $editable,
name: stringify!($name),
value: serde_json::to_value(&cfg.$name).unwrap_or_default(),
default: serde_json::to_value(&def.$name).unwrap_or_default(),
value: make_config! { @formvalue &cfg.$name, $ty },
default: make_config! { @formvalue &def.$name, $ty },
write_only: make_config! { @writeonly $ty },
configured: make_config! { @configured &cfg.$name, $ty },
user_configured: make_config! { @userconfigured &usr.$name, $ty },
r#type: get_form_type(stringify!($ty)),
doc: get_doc(concat!($($doc),+)),
overridden: overridden.contains(&pastey::paste!(stringify!([<$name:upper>]))),
@ -661,8 +672,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. Manage it via the Admin 2FA page. Has no effect when DISABLE_ADMIN_TOKEN is enabled!
admin_totp_secret: Pass, false, option;
/// Admin TOTP secret |> Base32-encoded secret containing at least 16 bytes of secret data. When set, logging in to the admin page additionally requires a time-based one-time code. Leave blank to keep the saved secret. Has no effect when DISABLE_ADMIN_TOKEN is enabled!
admin_totp_secret: AdminTotpSecret, 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();
@ -932,6 +943,30 @@ make_config! {
},
}
impl ConfigBuilder {
/// Applies the write-only admin form semantics for the TOTP secret:
/// - an omitted value keeps the value currently stored in `config.json`;
/// - an empty value removes the stored override;
/// - a non-empty value replaces it.
fn prepare_admin_update(&mut self, current_user_config: &Self) {
self.admin_totp_secret = match self.admin_totp_secret.take() {
None => current_user_config.admin_totp_secret.clone(),
Some(secret) if secret.trim().is_empty() => None,
Some(secret) => Some(secret.trim().to_uppercase()),
};
}
}
fn validate_admin_totp_secret(secret: &str) -> Result<(), Error> {
let Ok(decoded_secret) = data_encoding::BASE32.decode(secret.trim().to_uppercase().as_bytes()) else {
err!("`ADMIN_TOTP_SECRET` is not a valid base32-encoded string")
};
if decoded_secret.len() < 16 {
err!("`ADMIN_TOTP_SECRET` must contain at least 128 bits (16 bytes) of secret data")
}
Ok(())
}
fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
// Validate connection URL is valid and DB feature is enabled
#[cfg(sqlite)]
@ -1271,10 +1306,8 @@ 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 let Some(ref secret) = cfg.admin_totp_secret {
validate_admin_totp_secret(secret)?;
}
}
@ -1468,15 +1501,12 @@ 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, 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.
// Remove values that are not editable. Preserve an omitted write-only TOTP value,
// while still allowing an explicit empty value to remove the saved override.
if ignore_non_editable {
let current_user_config = self.inner.read().unwrap()._usr.clone();
builder.prepare_admin_update(&current_user_config);
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
@ -1612,34 +1642,6 @@ 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<String>) -> 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 active, and where the secret comes from:
/// - `"environment"`: set via the `ADMIN_TOTP_SECRET` env var; it can only be changed
/// there and therefore cannot be disabled from the admin page.
/// - `"config"`: present in the saved config file; it can be managed from the admin page.
/// A secret written to the config file by hand is indistinguishable from one enrolled
/// via the admin page, so both are reported as `"config"`.
///
/// The environment is checked first: if the secret is set there it stays effective even
/// after removing a (stale) copy from the config file, so it must not be reported as removable.
pub fn admin_totp_status(&self) -> (bool, &'static str) {
let inner = self.inner.read().unwrap();
let is_set = |v: &Option<String>| v.as_deref().is_some_and(|s| !s.trim().is_empty());
if is_set(&inner._env.admin_totp_secret) {
(true, "environment")
} else if is_set(&inner._usr.admin_totp_secret) {
(true, "config")
} else {
(false, "none")
}
}
pub fn is_webauthn_2fa_supported(&self) -> bool {
Url::parse(&self.domain()).expect("DOMAIN not a valid URL").domain().is_some()
}
@ -1789,8 +1791,6 @@ where
reg!("admin/users");
reg!("admin/organizations");
reg!("admin/diagnostics");
reg!("admin/totp");
reg!("404");
reg!(@withfallback "scss/vaultwarden.scss");
@ -1877,3 +1877,189 @@ 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::*;
const ENV_TOTP_SECRET: &str = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP";
const USER_TOTP_SECRET: &str = "KRSXG5DSNFXGOIDBKRSXG5DSNFXGOIDB";
fn config_with(env: ConfigBuilder, usr: ConfigBuilder) -> Config {
let mut overrides = Vec::new();
let config = env.merge(&usr, false, &mut overrides).build();
Config {
inner: RwLock::new(Inner {
rocket_shutdown_handle: None,
templates: Handlebars::new(),
config,
_env: env,
_usr: usr,
_overrides: overrides,
}),
}
}
fn admin_totp_element(config: &Config) -> serde_json::Value {
config
.prepare_json()
.as_array()
.unwrap()
.iter()
.flat_map(|group| group["elements"].as_array().unwrap())
.find(|element| element["name"] == "admin_totp_secret")
.unwrap()
.clone()
}
#[test]
fn config_file_admin_totp_secret_overrides_environment() {
let env = ConfigBuilder {
admin_totp_secret: Some(ENV_TOTP_SECRET.to_owned()),
..Default::default()
};
let usr = ConfigBuilder {
admin_totp_secret: Some(USER_TOTP_SECRET.to_owned()),
..Default::default()
};
let config = config_with(env, usr);
assert_eq!(config.admin_totp_secret().as_deref(), Some(USER_TOTP_SECRET));
assert!(config.inner.read().unwrap()._overrides.contains(&"ADMIN_TOTP_SECRET"));
}
#[test]
fn admin_totp_secret_is_write_only_in_settings_json() {
let env = ConfigBuilder {
admin_totp_secret: Some(ENV_TOTP_SECRET.to_owned()),
..Default::default()
};
let usr = ConfigBuilder {
admin_totp_secret: Some(USER_TOTP_SECRET.to_owned()),
..Default::default()
};
let config = config_with(env, usr);
let json = config.prepare_json();
let serialized = json.to_string();
let support_json = config.get_support_json().to_string();
assert!(!serialized.contains(ENV_TOTP_SECRET));
assert!(!serialized.contains(USER_TOTP_SECRET));
assert!(!support_json.contains(ENV_TOTP_SECRET));
assert!(!support_json.contains(USER_TOTP_SECRET));
let element = admin_totp_element(&config);
assert_eq!(element["value"], serde_json::Value::Null);
assert_eq!(element["default"], serde_json::Value::Null);
assert_eq!(element["write_only"], true);
assert_eq!(element["configured"], true);
assert_eq!(element["user_configured"], true);
assert_eq!(element["overridden"], true);
let empty_override = config_with(
ConfigBuilder {
admin_totp_secret: Some(ENV_TOTP_SECRET.to_owned()),
..Default::default()
},
ConfigBuilder {
admin_totp_secret: Some(String::new()),
..Default::default()
},
);
let element = admin_totp_element(&empty_override);
assert_eq!(element["configured"], false);
assert_eq!(element["user_configured"], true);
assert_eq!(element["overridden"], true);
}
#[test]
fn admin_settings_template_never_renders_admin_totp_secret() {
let env = ConfigBuilder {
admin_totp_secret: Some(ENV_TOTP_SECRET.to_owned()),
..Default::default()
};
let usr = ConfigBuilder {
admin_totp_secret: Some(USER_TOTP_SECRET.to_owned()),
..Default::default()
};
let config = config_with(env, usr);
let mut handlebars = Handlebars::new();
handlebars.set_strict_mode(true);
handlebars.register_helper("case", Box::new(case_helper));
handlebars
.register_template_string("admin/settings", include_str!("static/templates/admin/settings.hbs"))
.unwrap();
let rendered = handlebars
.render(
"admin/settings",
&serde_json::json!({
"page_data": {
"config": config.prepare_json(),
"can_backup": false,
},
"urlpath": "",
}),
)
.unwrap();
assert!(!rendered.contains(ENV_TOTP_SECRET));
assert!(!rendered.contains(USER_TOTP_SECRET));
assert!(rendered.contains("id=\"input_admin_totp_secret\""));
assert!(rendered.contains("placeholder=\"Configured — enter a new value to replace it\""));
assert!(rendered.contains("data-bs-target=\"#adminTotpQrDialog\""));
assert!(rendered.contains("id=\"adminTotpQrCode\""));
assert!(rendered.contains("/vw_static/qrcode-generator-2.0.4.js"));
assert!(rendered.find("</form>").unwrap() < rendered.find("id=\"adminTotpQrDialog\"").unwrap());
}
#[test]
fn admin_totp_secret_form_supports_keep_set_and_clear() {
let current = ConfigBuilder {
admin_totp_secret: Some(USER_TOTP_SECRET.to_owned()),
..Default::default()
};
let mut keep = ConfigBuilder::default();
keep.prepare_admin_update(&current);
assert_eq!(keep.admin_totp_secret.as_deref(), Some(USER_TOTP_SECRET));
let mut no_saved_override = ConfigBuilder::default();
no_saved_override.prepare_admin_update(&ConfigBuilder::default());
assert_eq!(no_saved_override.admin_totp_secret, None);
let mut replace = ConfigBuilder {
admin_totp_secret: Some(format!(" {} ", ENV_TOTP_SECRET.to_lowercase())),
..Default::default()
};
replace.prepare_admin_update(&current);
assert_eq!(replace.admin_totp_secret.as_deref(), Some(ENV_TOTP_SECRET));
let mut clear = ConfigBuilder {
admin_totp_secret: Some(" ".to_owned()),
..Default::default()
};
clear.prepare_admin_update(&current);
assert_eq!(clear.admin_totp_secret, None);
let env = ConfigBuilder {
admin_totp_secret: Some(ENV_TOTP_SECRET.to_owned()),
..Default::default()
};
let mut overrides = Vec::new();
assert_eq!(
env.merge(&clear, false, &mut overrides).build().admin_totp_secret.as_deref(),
Some(ENV_TOTP_SECRET)
);
}
#[test]
fn admin_totp_secret_validation_rejects_empty_short_and_invalid_values() {
for secret in ["", "JBSWY3DPEHPK3PXP", "not-base32"] {
assert!(validate_admin_totp_secret(secret).is_err(), "secret should be rejected: {secret:?}");
}
assert!(validate_admin_totp_secret(USER_TOTP_SECRET).is_ok());
}
}

155
src/static/scripts/admin_settings.js

@ -1,6 +1,126 @@
"use strict";
/* eslint-env es2017, browser */
/* global _post:readable, BASE_URL:readable */
/* global _post:readable, BASE_URL:readable, qrcode:readable */
const ADMIN_TOTP_SECRET_BYTES = 20;
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let generatedAdminTotpSecret = null;
function encodeBase32(bytes) {
let output = "";
let buffer = 0;
let bits = 0;
bytes.forEach(byte => {
buffer = (buffer << 8) | byte;
bits += 8;
while (bits >= 5) {
bits -= 5;
output += BASE32_ALPHABET[(buffer >>> bits) & 31];
}
buffer &= (1 << bits) - 1;
});
if (bits > 0) {
output += BASE32_ALPHABET[(buffer << (5 - bits)) & 31];
}
return output;
}
function buildAdminTotpUri(secret) {
const issuer = "Vaultwarden";
const account = "Admin";
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;
return `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(issuer)}` +
"&algorithm=SHA1&digits=6&period=30";
}
function resetAdminTotpQrDialog() {
generatedAdminTotpSecret = null;
const secretOutput = document.getElementById("adminTotpQrSecret");
const qrImage = document.getElementById("adminTotpQrCode");
const error = document.getElementById("adminTotpQrError");
const useButton = document.getElementById("adminTotpQrUse");
secretOutput.value = "";
qrImage.removeAttribute("src");
qrImage.classList.add("d-none");
error.textContent = "";
error.classList.add("d-none");
useButton.disabled = true;
}
function showAdminTotpQrError(message) {
const error = document.getElementById("adminTotpQrError");
error.textContent = message;
error.classList.remove("d-none");
}
function generateAdminTotpQr() {
resetAdminTotpQrDialog();
if (!window.crypto || typeof window.crypto.getRandomValues !== "function") {
showAdminTotpQrError("Secure random number generation is not available in this browser.");
return;
}
if (typeof qrcode !== "function") {
showAdminTotpQrError("The local QR code generator could not be loaded.");
return;
}
const randomBytes = new Uint8Array(ADMIN_TOTP_SECRET_BYTES);
let secret;
try {
window.crypto.getRandomValues(randomBytes);
secret = encodeBase32(randomBytes);
} catch (_error) {
showAdminTotpQrError("Unable to generate a secure TOTP secret in this browser.");
return;
} finally {
randomBytes.fill(0);
}
try {
const qr = qrcode(0, "M");
qr.addData(buildAdminTotpUri(secret), "Byte");
qr.make();
generatedAdminTotpSecret = secret;
document.getElementById("adminTotpQrSecret").value = secret;
const qrImage = document.getElementById("adminTotpQrCode");
qrImage.src = qr.createDataURL(5, 20);
qrImage.classList.remove("d-none");
document.getElementById("adminTotpQrUse").disabled = false;
} catch (_error) {
resetAdminTotpQrDialog();
showAdminTotpQrError("Unable to create the QR code in this browser.");
}
}
function regenerateAdminTotpQr() {
if (generatedAdminTotpSecret &&
!confirm("Generate a different secret? If you already scanned this QR code, you will need to scan the new one.")) {
return;
}
generateAdminTotpQr();
}
function useGeneratedAdminTotpSecret() {
if (!generatedAdminTotpSecret) {
return;
}
const input = document.getElementById("input_admin_totp_secret");
const clear = document.querySelector('[data-vw-clear-target="input_admin_totp_secret"]');
input.disabled = false;
input.value = generatedAdminTotpSecret;
if (clear) {
clear.checked = false;
}
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function smtpTest(event) {
event.preventDefault();
@ -40,6 +160,17 @@ function getFormData() {
document.querySelectorAll(".conf-text, .conf-password").forEach(function (e) {
data[e.name] = e.value || null;
});
document.querySelectorAll(".conf-write-only").forEach(function (e) {
const clear = document.querySelector(`[data-vw-clear-target="${e.id}"]`);
if (clear && clear.checked) {
// An explicit empty string removes the saved config value.
data[e.name] = "";
} else if (e.value.trim()) {
// Omitting an untouched write-only field keeps the currently saved value.
data[e.name] = e.value.trim();
}
});
return data;
}
@ -141,6 +272,14 @@ function toggleVis(event) {
}
}
function toggleWriteOnlyClear(event) {
const input = document.getElementById(event.target.dataset.vwClearTarget);
input.disabled = event.target.checked;
if (event.target.checked) {
input.value = "";
}
}
function masterCheck(check_id, inputs_query) {
function onChanged(checkbox, inputs_query) {
return function _fn() {
@ -213,6 +352,18 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => {
password_toggle_btn.addEventListener("click", toggleVis);
});
document.querySelectorAll("input[data-vw-clear-target]").forEach(clear_checkbox => {
clear_checkbox.addEventListener("change", toggleWriteOnlyClear);
});
const adminTotpQrDialog = document.getElementById("adminTotpQrDialog");
if (adminTotpQrDialog) {
adminTotpQrDialog.addEventListener("show.bs.modal", generateAdminTotpQr);
adminTotpQrDialog.addEventListener("hidden.bs.modal", resetAdminTotpQrDialog);
document.getElementById("adminTotpQrRegenerate").addEventListener("click", regenerateAdminTotpQr);
document.getElementById("adminTotpQrUse").addEventListener("click", useGeneratedAdminTotpSecret);
}
const btnBackupDatabase = document.getElementById("backupDatabase");
if (btnBackupDatabase) {
btnBackupDatabase.addEventListener("click", backupDatabase);
@ -229,4 +380,4 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => {
config_form.addEventListener("submit", saveConfig);
showWarnings();
});
});

63
src/static/scripts/admin_totp.js

@ -1,63 +0,0 @@
"use strict";
/* eslint-env es2017, browser */
/* global BASE_URL:readable, _post:readable */
function totpGenerate() {
fetch(`${BASE_URL}/admin/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}/admin/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}/admin/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);
});

21
src/static/scripts/qrcode-generator-2.0.4.LICENSE.txt

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2009 Kazuhiko Arase
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

2
src/static/scripts/qrcode-generator-2.0.4.js

File diff suppressed because one or more lines are too long

3
src/static/templates/admin/base.hbs

@ -48,9 +48,6 @@
<li class="nav-item">
<a class="nav-link" href="{{urlpath}}/admin/diagnostics">Diagnostics</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{urlpath}}/admin/totp">Admin 2FA</a>
</li>
{{/if}}
<li class="nav-item">
<a class="nav-link" href="{{urlpath}}/" target="_blank" rel="noreferrer">Vault</a>

74
src/static/templates/admin/settings.hbs

@ -26,6 +26,40 @@
{{#case type "text" "number" "password"}}
<label for="input_{{name}}" class="col-sm-3 col-form-label">{{doc.name}}</label>
<div class="col-sm-8">
{{#if write_only}}
<div class="input-group">
<input class="form-control conf-write-only" id="input_{{name}}" type="password"
name="{{name}}" value="" autocomplete="off" spellcheck="false"
{{#if configured}}
placeholder="Configured — enter a new value to replace it"
{{else}}
placeholder="Enter a new value"
{{/if}}>
{{#if (eq name "admin_totp_secret")}}
<button class="btn btn-outline-primary input-group-text" type="button"
data-bs-toggle="modal" data-bs-target="#adminTotpQrDialog">Generate QR</button>
{{/if}}
<button class="btn btn-outline-secondary input-group-text" type="button" data-vw-pw-toggle="input_{{name}}">Show/hide</button>
</div>
{{#if user_configured}}
<div class="form-check mt-1">
<input class="form-check-input conf-write-only-clear" type="checkbox" id="clear_{{name}}"
data-vw-clear-target="input_{{name}}">
<label class="form-check-label" for="clear_{{name}}">
{{#if overridden}}
Remove saved override (the environment value will become active)
{{else}}
Remove saved value
{{/if}}
</label>
</div>
{{else}}
{{#if configured}}
<div class="form-text">Set by the environment. Enter a value to save a config override.</div>
{{/if}}
{{/if}}
<div class="form-text">When replacing this secret, add the new value to your authenticator before saving. Closing the current session before testing it may lock you out.</div>
{{else}}
<div class="input-group">
<input class="form-control conf-{{type}}" id="input_{{name}}" type="{{type}}"
name="{{name}}" value="{{value}}" {{#if default}} placeholder="Default: {{default}}"{{/if}}>
@ -33,6 +67,7 @@
<button class="btn btn-outline-secondary input-group-text" type="button" data-vw-pw-toggle="input_{{name}}">Show/hide</button>
{{/case}}
</div>
{{/if}}
</div>
{{/case}}
{{#case type "checkbox"}}
@ -140,6 +175,39 @@
</form>
</div>
</div>
<div id="adminTotpQrDialog" class="modal fade" tabindex="-1" aria-labelledby="adminTotpQrDialogTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="adminTotpQrDialogTitle">Set up Admin TOTP</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
A new 20-byte secret and QR code are generated only in this browser. Nothing is sent or saved
until you use the generated secret and save the configuration.
</p>
<div id="adminTotpQrError" class="alert alert-danger d-none" role="alert"></div>
<div class="text-center mb-3">
<img id="adminTotpQrCode" class="d-none img-fluid border rounded" alt="QR code for the generated Admin TOTP secret">
</div>
<label for="adminTotpQrSecret" class="form-label">Manual setup key</label>
<input id="adminTotpQrSecret" class="form-control font-monospace" type="text" value="" readonly
autocomplete="off" spellcheck="false">
<div class="alert alert-warning mt-3 mb-0">
Scan the QR code before using this secret. This helper does not verify your authenticator.
Keep the current admin session open and test the new code before logging out.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" id="adminTotpQrRegenerate">Generate another</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="adminTotpQrUse" data-bs-dismiss="modal" disabled>Use generated secret</button>
</div>
</div>
</div>
</div>
</main>
<style>
#config-block ::placeholder {
@ -152,5 +220,11 @@
--bs-alert-bg: #fff3cd;
--bs-alert-border-color: #ffecb5;
}
#adminTotpQrCode {
width: 285px;
height: auto;
}
</style>
<script src="{{urlpath}}/vw_static/qrcode-generator-2.0.4.js"></script>
<script src="{{urlpath}}/vw_static/admin_settings.js"></script>

59
src/static/templates/admin/totp.hbs

@ -1,59 +0,0 @@
<main class="container-xxl">
<div id="admin-totp" class="content col-lg-8">
<div class="card shadow mb-4">
<div class="card-header">Admin page two-factor authentication (TOTP)</div>
<div class="card-body">
{{#if page_data.enabled}}
{{#if page_data.via_env}}
<p>
<span class="badge bg-success">Enabled</span>
via the <code>ADMIN_TOTP_SECRET</code> environment variable.
</p>
<p class="mb-0">To disable or change it, update the environment variable and restart Vaultwarden.</p>
{{else}}
<p><span class="badge bg-success">Enabled</span></p>
<p>Logging in to this admin page requires a time-based one-time code from your authenticator app.</p>
<div class="row g-2">
<div class="col-auto">
<input type="text" id="totp-disable-code" class="form-control" inputmode="numeric"
autocomplete="one-time-code" maxlength="6" placeholder="Current 2FA code">
</div>
<div class="col-auto">
<button type="button" id="totp-disable" class="btn btn-danger">Disable 2FA</button>
</div>
</div>
{{/if}}
{{else}}
<p><span class="badge bg-danger">Disabled</span></p>
<p>
Protect this admin page with a second factor: generate a secret, scan the QR code with your
authenticator app and confirm with a code. It takes effect immediately, no restart needed.
</p>
<button type="button" id="totp-generate" class="btn btn-primary">Enable 2FA</button>
<div id="totp-enroll" class="d-none mt-4">
<div class="row">
<div class="col-auto mb-3">
<div id="totp-qr" class="bg-white p-2 rounded border d-inline-block"></div>
</div>
<div class="col">
<p>Scan the QR code with your authenticator app, or enter the secret manually:</p>
<p><code id="totp-secret"></code></p>
<p>Then enter the current code from the app to verify and activate:</p>
<div class="row g-2">
<div class="col-auto">
<input type="text" id="totp-enable-code" class="form-control" inputmode="numeric"
autocomplete="one-time-code" maxlength="6" placeholder="6-digit code">
</div>
<div class="col-auto">
<button type="button" id="totp-enable" class="btn btn-success">Verify &amp; activate</button>
</div>
</div>
</div>
</div>
</div>
{{/if}}
</div>
</div>
</div>
</main>
<script src="{{urlpath}}/vw_static/admin_totp.js"></script>
Loading…
Cancel
Save