Browse Source

Add optional TOTP second factor for the admin page

pull/7444/head
tom27052006 4 days ago
parent
commit
77debecdc3
  1. 8
      .env.template
  2. 58
      src/api/admin.rs
  3. 9
      src/config.rs
  4. 3
      src/static/templates/admin/login.hbs

8
.env.template

@ -428,6 +428,14 @@
## Old plain text string (Will generate warnings in favor of Argon2) ## Old plain text string (Will generate warnings in favor of Argon2)
# ADMIN_TOKEN=Vy2VyYTTsKPv8W5aEOWUbB/Bt3DEKePbHmI4m9VcemUMS2rEviDowNAFqYi1xjmp # 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 ## 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 ## meant to be used with the use of a separate auth layer in front
# DISABLE_ADMIN_TOKEN=false # DISABLE_ADMIN_TOKEN=false

58
src/api/admin.rs

@ -1,4 +1,7 @@
use std::{env, sync::LazyLock}; use std::{
env,
sync::{LazyLock, Mutex},
};
use reqwest::Method; use reqwest::Method;
use rocket::{ use rocket::{
@ -167,6 +170,7 @@ fn render_admin_login(msg: Option<&str>, redirect: Option<&str>) -> ApiResult<Ht
"page_content": "admin/login", "page_content": "admin/login",
"error": msg, "error": msg,
"redirect": redirect, "redirect": redirect,
"totp_enabled": CONFIG.admin_totp_secret().is_some(),
"urlpath": CONFIG.domain_path() "urlpath": CONFIG.domain_path()
}); });
@ -178,6 +182,7 @@ fn render_admin_login(msg: Option<&str>, redirect: Option<&str>) -> ApiResult<Ht
#[derive(FromForm)] #[derive(FromForm)]
struct LoginForm { struct LoginForm {
token: String, token: String,
totp: Option<String>,
redirect: Option<String>, redirect: Option<String>,
} }
@ -198,8 +203,8 @@ fn post_admin_login(
))); )));
} }
// If the token is invalid, redirect to login page // If the token or the TOTP code is invalid, redirect to login page
if validate_token(&data.token) { 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 // If the token received is valid, generate JWT and save it as a cookie
let claims = generate_admin_claims(); let claims = generate_admin_claims();
let jwt = encode_jwt(&claims); let jwt = encode_jwt(&claims);
@ -218,9 +223,9 @@ fn post_admin_login(
Err(AdminResponse::Ok(render_admin_page())) Err(AdminResponse::Ok(render_admin_page()))
} }
} else { } else {
error!("Invalid admin token. IP: {}", ip.ip); error!("Invalid admin credentials. IP: {}", ip.ip);
Err(AdminResponse::Unauthorized(render_admin_login( Err(AdminResponse::Unauthorized(render_admin_login(
Some("Invalid admin token, please try again."), Some("Invalid credentials, please try again."),
redirect.as_deref(), 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<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;
};
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::<Sha1>(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)] #[derive(Serialize)]
struct AdminTemplateData { struct AdminTemplateData {
page_content: String, page_content: String,

9
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/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_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 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(); 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 { if cfg.increase_note_size_limit {

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

@ -14,6 +14,9 @@
<form class="form-inline" method="post" action="{{urlpath}}/admin"> <form class="form-inline" method="post" action="{{urlpath}}/admin">
<input type="password" autocomplete="password" class="form-control w-50 mr-2" name="token" placeholder="Enter admin token" autofocus="autofocus"> <input type="password" autocomplete="password" class="form-control w-50 mr-2" name="token" placeholder="Enter admin token" autofocus="autofocus">
{{#if totp_enabled}}
<input type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="form-control w-50 mr-2 mt-2" name="totp" placeholder="Enter 2FA code">
{{/if}}
{{#if redirect}} {{#if redirect}}
<input type="hidden" id="redirect" name="redirect" value="/{{redirect}}"> <input type="hidden" id="redirect" name="redirect" value="/{{redirect}}">
{{/if}} {{/if}}

Loading…
Cancel
Save