Browse Source

Add QR code enrollment and management page for admin TOTP

pull/7444/head
tom27052006 4 days ago
parent
commit
e049caa40e
  1. 2
      .env.template
  2. 7
      Cargo.lock
  3. 3
      Cargo.toml
  4. 95
      src/api/admin.rs
  5. 1
      src/api/web.rs
  6. 43
      src/config.rs
  7. 63
      src/static/scripts/admin_totp.js
  8. 3
      src/static/templates/admin/base.hbs
  9. 59
      src/static/templates/admin/totp.hbs

2
.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

7
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",

3
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"] }

95
src/api/admin.rs

@ -74,6 +74,10 @@ pub fn routes() -> Vec<Route> {
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<i64> = 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<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,

1
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")))

43
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<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 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<String>| 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");

63
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);
});

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

@ -48,6 +48,9 @@
<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>

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

@ -0,0 +1,59 @@
<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