mirror of
https://git.proxmox.com/git/proxmox-perl-rs
synced 2025-05-24 11:46:12 +00:00
pmg: add tfa module
Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
This commit is contained in:
parent
67882d1a95
commit
83ac34503e
@ -21,13 +21,16 @@ crate-type = [ "cdylib" ]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
libc = "0.2"
|
||||||
|
nix = "0.19"
|
||||||
openssl = "0.10.32"
|
openssl = "0.10.32"
|
||||||
serde = "1.0"
|
serde = "1.0"
|
||||||
serde_bytes = "0.11.3"
|
serde_bytes = "0.11.3"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
url = "2"
|
||||||
|
|
||||||
perlmod = { version = "0.9", features = [ "exporter" ] }
|
perlmod = { version = "0.9", features = [ "exporter" ] }
|
||||||
|
|
||||||
proxmox-acme-rs = { version = "0.3.1", features = ["client"] }
|
proxmox-acme-rs = { version = "0.3.1", features = ["client"] }
|
||||||
|
|
||||||
proxmox-apt = "0.8.0"
|
proxmox-apt = "0.8.0"
|
||||||
|
proxmox-tfa = { version = "2", features = ["api"] }
|
||||||
|
@ -18,9 +18,10 @@ PM_DIRS := \
|
|||||||
PMG/RS/APT
|
PMG/RS/APT
|
||||||
|
|
||||||
PM_FILES := \
|
PM_FILES := \
|
||||||
PMG/RS/Acme.pm \
|
|
||||||
PMG/RS/APT/Repositories.pm \
|
PMG/RS/APT/Repositories.pm \
|
||||||
PMG/RS/CSR.pm
|
PMG/RS/Acme.pm \
|
||||||
|
PMG/RS/CSR.pm \
|
||||||
|
PMG/RS/TFA.pm
|
||||||
|
|
||||||
ifeq ($(BUILD_MODE), release)
|
ifeq ($(BUILD_MODE), release)
|
||||||
CARGO_BUILD_ARGS += --release
|
CARGO_BUILD_ARGS += --release
|
||||||
|
@ -6,15 +6,20 @@ Build-Depends:
|
|||||||
debhelper (>= 12),
|
debhelper (>= 12),
|
||||||
librust-anyhow-1+default-dev,
|
librust-anyhow-1+default-dev,
|
||||||
librust-hex-0.4+default-dev,
|
librust-hex-0.4+default-dev,
|
||||||
|
librust-libc-0.2+default-dev,
|
||||||
|
librust-nix-0.19+default-dev,
|
||||||
librust-openssl-0.10+default-dev (>= 0.10.32-~~),
|
librust-openssl-0.10+default-dev (>= 0.10.32-~~),
|
||||||
librust-perlmod-0.8+default-dev,
|
librust-perlmod-0.8+default-dev (>= 0.8.1-~~),
|
||||||
librust-perlmod-0.8+exporter-dev,
|
librust-perlmod-0.8+exporter-dev (>= 0.8.1-~~),
|
||||||
librust-proxmox-acme-rs-0.3+client-dev (>= 0.3.1-~~),
|
librust-proxmox-acme-rs-0.3+client-dev (>= 0.3.1-~~),
|
||||||
librust-proxmox-acme-rs-0.3+default-dev (>= 0.3.1-~~),
|
librust-proxmox-acme-rs-0.3+default-dev (>= 0.3.1-~~),
|
||||||
librust-proxmox-apt-0.8+default-dev,
|
librust-proxmox-apt-0.8+default-dev,
|
||||||
|
librust-proxmox-tfa-2+api-dev,
|
||||||
|
librust-proxmox-tfa-2+default-dev,
|
||||||
librust-serde-1+default-dev,
|
librust-serde-1+default-dev,
|
||||||
librust-serde-bytes-0.11+default-dev (>= 0.11.3-~~),
|
librust-serde-bytes-0.11+default-dev (>= 0.11.3-~~),
|
||||||
librust-serde-json-1+default-dev,
|
librust-serde-json-1+default-dev,
|
||||||
|
librust-url-2+default-dev,
|
||||||
Standards-Version: 4.3.0
|
Standards-Version: 4.3.0
|
||||||
Homepage: https://www.proxmox.com
|
Homepage: https://www.proxmox.com
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
pub mod acme;
|
pub mod acme;
|
||||||
pub mod apt;
|
pub mod apt;
|
||||||
pub mod csr;
|
pub mod csr;
|
||||||
|
pub mod tfa;
|
||||||
|
603
pmg-rs/src/tfa.rs
Normal file
603
pmg-rs/src/tfa.rs
Normal file
@ -0,0 +1,603 @@
|
|||||||
|
//! This implements the `tfa.cfg` parser & TFA API calls for PMG.
|
||||||
|
//!
|
||||||
|
//! The exported `PMG::RS::TFA` perl package provides access to rust's `TfaConfig`.
|
||||||
|
//! Contrary to the PVE implementation, this does not need to provide any backward compatible
|
||||||
|
//! entries.
|
||||||
|
//!
|
||||||
|
//! NOTE: In PMG the tfa config is behind `PVE::INotify`'s `ccache`, so PMG sets it to `noclone` in
|
||||||
|
//! order to avoid losing the rust magic-ref.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{self, Read};
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
use std::os::unix::io::{AsRawFd, RawFd};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{bail, format_err, Error};
|
||||||
|
use nix::errno::Errno;
|
||||||
|
use nix::sys::stat::Mode;
|
||||||
|
|
||||||
|
pub(self) use proxmox_tfa::api::{
|
||||||
|
RecoveryState, TfaChallenge, TfaConfig, TfaResponse, U2fConfig, WebauthnConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[perlmod::package(name = "PMG::RS::TFA")]
|
||||||
|
mod export {
|
||||||
|
use std::convert::TryInto;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use anyhow::{bail, format_err, Error};
|
||||||
|
use serde_bytes::ByteBuf;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use perlmod::Value;
|
||||||
|
use proxmox_tfa::api::methods;
|
||||||
|
|
||||||
|
use super::{TfaConfig, UserAccess};
|
||||||
|
|
||||||
|
perlmod::declare_magic!(Box<Tfa> : &Tfa as "PMG::RS::TFA");
|
||||||
|
|
||||||
|
/// A TFA Config instance.
|
||||||
|
pub struct Tfa {
|
||||||
|
inner: Mutex<TfaConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prevent 'dclone'.
|
||||||
|
#[export(name = "STORABLE_freeze", raw_return)]
|
||||||
|
fn storable_freeze(#[try_from_ref] _this: &Tfa, _cloning: bool) -> Result<Value, Error> {
|
||||||
|
bail!("freezing TFA config not supported!");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a TFA configuration.
|
||||||
|
#[export(raw_return)]
|
||||||
|
fn new(#[raw] class: Value, config: &[u8]) -> Result<Value, Error> {
|
||||||
|
let mut inner: TfaConfig = serde_json::from_slice(config)
|
||||||
|
.map_err(|err| format_err!("failed to parse TFA file: {}", err))?;
|
||||||
|
|
||||||
|
// PMG does not support U2F.
|
||||||
|
inner.u2f = None;
|
||||||
|
Ok(perlmod::instantiate_magic!(
|
||||||
|
&class, MAGIC => Box::new(Tfa { inner: Mutex::new(inner) })
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the configuration out into a JSON string.
|
||||||
|
#[export]
|
||||||
|
fn write(#[try_from_ref] this: &Tfa) -> Result<serde_bytes::ByteBuf, Error> {
|
||||||
|
let inner = this.inner.lock().unwrap();
|
||||||
|
Ok(ByteBuf::from(serde_json::to_vec(&*inner)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debug helper: serialize the TFA user data into a perl value.
|
||||||
|
#[export]
|
||||||
|
fn to_perl(#[try_from_ref] this: &Tfa) -> Result<Value, Error> {
|
||||||
|
let inner = this.inner.lock().unwrap();
|
||||||
|
Ok(perlmod::to_value(&*inner)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a list of all the user names in this config.
|
||||||
|
/// PMG uses this to verify users and purge the invalid ones.
|
||||||
|
#[export]
|
||||||
|
fn users(#[try_from_ref] this: &Tfa) -> Result<Vec<String>, Error> {
|
||||||
|
Ok(this.inner.lock().unwrap().users.keys().cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a user from the TFA configuration.
|
||||||
|
#[export]
|
||||||
|
fn remove_user(#[try_from_ref] this: &Tfa, userid: &str) -> Result<bool, Error> {
|
||||||
|
Ok(this.inner.lock().unwrap().users.remove(userid).is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the TFA data for a specific user.
|
||||||
|
#[export(raw_return)]
|
||||||
|
fn get_user(#[try_from_ref] this: &Tfa, userid: &str) -> Result<Value, perlmod::Error> {
|
||||||
|
perlmod::to_value(&this.inner.lock().unwrap().users.get(userid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a u2f registration. This modifies the config (adds the user to it), so it needs be
|
||||||
|
/// written out.
|
||||||
|
#[export]
|
||||||
|
fn add_u2f_registration(
|
||||||
|
#[raw] raw_this: Value,
|
||||||
|
//#[try_from_ref] this: &Tfa,
|
||||||
|
userid: &str,
|
||||||
|
description: String,
|
||||||
|
) -> Result<String, Error> {
|
||||||
|
let this: &Tfa = (&raw_this).try_into()?;
|
||||||
|
let mut inner = this.inner.lock().unwrap();
|
||||||
|
inner.u2f_registration_challenge(UserAccess::new(&raw_this)?, userid, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finish a u2f registration. This updates temporary data in `/run` and therefore the config
|
||||||
|
/// needs to be written out!
|
||||||
|
#[export]
|
||||||
|
fn finish_u2f_registration(
|
||||||
|
#[raw] raw_this: Value,
|
||||||
|
//#[try_from_ref] this: &Tfa,
|
||||||
|
userid: &str,
|
||||||
|
challenge: &str,
|
||||||
|
response: &str,
|
||||||
|
) -> Result<String, Error> {
|
||||||
|
let this: &Tfa = (&raw_this).try_into()?;
|
||||||
|
let mut inner = this.inner.lock().unwrap();
|
||||||
|
inner.u2f_registration_finish(UserAccess::new(&raw_this)?, userid, challenge, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a user has any TFA entries of a given type.
|
||||||
|
#[export]
|
||||||
|
fn has_type(#[try_from_ref] this: &Tfa, userid: &str, typename: &str) -> Result<bool, Error> {
|
||||||
|
Ok(match this.inner.lock().unwrap().users.get(userid) {
|
||||||
|
Some(user) => match typename {
|
||||||
|
"totp" | "oath" => !user.totp.is_empty(),
|
||||||
|
"u2f" => !user.u2f.is_empty(),
|
||||||
|
"webauthn" => !user.webauthn.is_empty(),
|
||||||
|
"yubico" => !user.yubico.is_empty(),
|
||||||
|
"recovery" => match &user.recovery {
|
||||||
|
Some(r) => r.count_available() > 0,
|
||||||
|
None => false,
|
||||||
|
},
|
||||||
|
_ => bail!("unrecognized TFA type {:?}", typename),
|
||||||
|
},
|
||||||
|
None => false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates a space separated list of yubico keys of this account.
|
||||||
|
#[export]
|
||||||
|
fn get_yubico_keys(#[try_from_ref] this: &Tfa, userid: &str) -> Result<Option<String>, Error> {
|
||||||
|
Ok(this.inner.lock().unwrap().users.get(userid).map(|user| {
|
||||||
|
user.enabled_yubico_entries()
|
||||||
|
.fold(String::new(), |mut s, k| {
|
||||||
|
if !s.is_empty() {
|
||||||
|
s.push(' ');
|
||||||
|
}
|
||||||
|
s.push_str(k);
|
||||||
|
s
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[export]
|
||||||
|
fn set_u2f_config(#[try_from_ref] this: &Tfa, config: Option<super::U2fConfig>) {
|
||||||
|
this.inner.lock().unwrap().u2f = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[export]
|
||||||
|
fn set_webauthn_config(
|
||||||
|
#[try_from_ref] this: &Tfa,
|
||||||
|
config: Option<super::WebauthnConfig>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
this.inner.lock().unwrap().webauthn = config.map(TryInto::try_into).transpose()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[export]
|
||||||
|
fn get_webauthn_config(
|
||||||
|
#[try_from_ref] this: &Tfa,
|
||||||
|
) -> Result<(Option<String>, Option<super::WebauthnConfig>), Error> {
|
||||||
|
Ok(match this.inner.lock().unwrap().webauthn.clone() {
|
||||||
|
Some(config) => (Some(hex::encode(&config.digest())), Some(config.into())),
|
||||||
|
None => (None, None),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[export]
|
||||||
|
fn has_webauthn_origin(#[try_from_ref] this: &Tfa) -> bool {
|
||||||
|
match &this.inner.lock().unwrap().webauthn {
|
||||||
|
Some(wa) => wa.origin.is_some(),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an authentication challenge.
|
||||||
|
///
|
||||||
|
/// Returns the challenge as a json string.
|
||||||
|
/// Returns `undef` if no second factor is configured.
|
||||||
|
#[export]
|
||||||
|
fn authentication_challenge(
|
||||||
|
#[raw] raw_this: Value,
|
||||||
|
//#[try_from_ref] this: &Tfa,
|
||||||
|
userid: &str,
|
||||||
|
origin: Option<Url>,
|
||||||
|
) -> Result<Option<String>, Error> {
|
||||||
|
let this: &Tfa = (&raw_this).try_into()?;
|
||||||
|
let mut inner = this.inner.lock().unwrap();
|
||||||
|
match inner.authentication_challenge(
|
||||||
|
UserAccess::new(&raw_this)?,
|
||||||
|
userid,
|
||||||
|
origin.as_ref(),
|
||||||
|
)? {
|
||||||
|
Some(challenge) => Ok(Some(serde_json::to_string(&challenge)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the recovery state (suitable for a challenge object).
|
||||||
|
#[export]
|
||||||
|
fn recovery_state(#[try_from_ref] this: &Tfa, userid: &str) -> Option<super::RecoveryState> {
|
||||||
|
this.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.users
|
||||||
|
.get(userid)
|
||||||
|
.and_then(|user| {
|
||||||
|
let state = user.recovery_state();
|
||||||
|
state.is_available().then(move || state)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the TFA challenge string (which is a json object) and verifies ther esponse against
|
||||||
|
/// it.
|
||||||
|
///
|
||||||
|
/// NOTE: This returns a boolean whether the config data needs to be *saved* after this call
|
||||||
|
/// (to use up recovery keys!).
|
||||||
|
#[export]
|
||||||
|
fn authentication_verify(
|
||||||
|
#[raw] raw_this: Value,
|
||||||
|
//#[try_from_ref] this: &Tfa,
|
||||||
|
userid: &str,
|
||||||
|
challenge: &str, //super::TfaChallenge,
|
||||||
|
response: &str,
|
||||||
|
origin: Option<Url>,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
let this: &Tfa = (&raw_this).try_into()?;
|
||||||
|
let challenge: super::TfaChallenge = serde_json::from_str(challenge)?;
|
||||||
|
let response: super::TfaResponse = response.parse()?;
|
||||||
|
let mut inner = this.inner.lock().unwrap();
|
||||||
|
inner
|
||||||
|
.verify(
|
||||||
|
UserAccess::new(&raw_this)?,
|
||||||
|
userid,
|
||||||
|
&challenge,
|
||||||
|
response,
|
||||||
|
origin.as_ref(),
|
||||||
|
)
|
||||||
|
.map(|save| save.needs_saving())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DEBUG HELPER: Get the current TOTP value for a given TOTP URI.
|
||||||
|
#[export]
|
||||||
|
fn get_current_totp_value(otp_uri: &str) -> Result<String, Error> {
|
||||||
|
let totp: proxmox_tfa::totp::Totp = otp_uri.parse()?;
|
||||||
|
Ok(totp.time(std::time::SystemTime::now())?.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[export]
|
||||||
|
fn api_list_user_tfa(
|
||||||
|
#[try_from_ref] this: &Tfa,
|
||||||
|
userid: &str,
|
||||||
|
) -> Result<Vec<methods::TypedTfaInfo>, Error> {
|
||||||
|
methods::list_user_tfa(&this.inner.lock().unwrap(), userid)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[export]
|
||||||
|
fn api_get_tfa_entry(
|
||||||
|
#[try_from_ref] this: &Tfa,
|
||||||
|
userid: &str,
|
||||||
|
id: &str,
|
||||||
|
) -> Option<methods::TypedTfaInfo> {
|
||||||
|
methods::get_tfa_entry(&this.inner.lock().unwrap(), userid, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the user still has other TFA entries left, `false` if the user has *no*
|
||||||
|
/// more tfa entries.
|
||||||
|
#[export]
|
||||||
|
fn api_delete_tfa(#[try_from_ref] this: &Tfa, userid: &str, id: String) -> Result<bool, Error> {
|
||||||
|
let mut this = this.inner.lock().unwrap();
|
||||||
|
match methods::delete_tfa(&mut this, userid, &id) {
|
||||||
|
Ok(has_entries_left) => Ok(has_entries_left),
|
||||||
|
Err(methods::EntryNotFound) => bail!("no such entry"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[export]
|
||||||
|
fn api_list_tfa(
|
||||||
|
#[try_from_ref] this: &Tfa,
|
||||||
|
authid: &str,
|
||||||
|
top_level_allowed: bool,
|
||||||
|
) -> Result<Vec<methods::TfaUser>, Error> {
|
||||||
|
methods::list_tfa(&this.inner.lock().unwrap(), authid, top_level_allowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[export]
|
||||||
|
fn api_add_tfa_entry(
|
||||||
|
#[raw] raw_this: Value,
|
||||||
|
//#[try_from_ref] this: &Tfa,
|
||||||
|
userid: &str,
|
||||||
|
description: Option<String>,
|
||||||
|
totp: Option<String>,
|
||||||
|
value: Option<String>,
|
||||||
|
challenge: Option<String>,
|
||||||
|
ty: methods::TfaType,
|
||||||
|
origin: Option<Url>,
|
||||||
|
) -> Result<methods::TfaUpdateInfo, Error> {
|
||||||
|
let this: &Tfa = (&raw_this).try_into()?;
|
||||||
|
methods::add_tfa_entry(
|
||||||
|
&mut this.inner.lock().unwrap(),
|
||||||
|
UserAccess::new(&raw_this)?,
|
||||||
|
userid,
|
||||||
|
description,
|
||||||
|
totp,
|
||||||
|
value,
|
||||||
|
challenge,
|
||||||
|
ty,
|
||||||
|
origin.as_ref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a totp entry without validating it, used for user.cfg keys.
|
||||||
|
/// Returns the ID.
|
||||||
|
#[export]
|
||||||
|
fn add_totp_entry(
|
||||||
|
#[try_from_ref] this: &Tfa,
|
||||||
|
userid: &str,
|
||||||
|
description: String,
|
||||||
|
totp: String,
|
||||||
|
) -> Result<String, Error> {
|
||||||
|
Ok(this
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.add_totp(userid, description, totp.parse()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a yubico entry without validating it, used for user.cfg keys.
|
||||||
|
/// Returns the ID.
|
||||||
|
#[export]
|
||||||
|
fn add_yubico_entry(
|
||||||
|
#[try_from_ref] this: &Tfa,
|
||||||
|
userid: &str,
|
||||||
|
description: String,
|
||||||
|
yubico: String,
|
||||||
|
) -> String {
|
||||||
|
this.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.add_yubico(userid, description, yubico)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[export]
|
||||||
|
fn api_update_tfa_entry(
|
||||||
|
#[try_from_ref] this: &Tfa,
|
||||||
|
userid: &str,
|
||||||
|
id: &str,
|
||||||
|
description: Option<String>,
|
||||||
|
enable: Option<bool>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
match methods::update_tfa_entry(
|
||||||
|
&mut this.inner.lock().unwrap(),
|
||||||
|
userid,
|
||||||
|
id,
|
||||||
|
description,
|
||||||
|
enable,
|
||||||
|
) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(methods::EntryNotFound) => bail!("no such entry"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach the path to errors from [`nix::mkir()`].
|
||||||
|
pub(crate) fn mkdir<P: AsRef<Path>>(path: P, mode: libc::mode_t) -> Result<(), Error> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
match nix::unistd::mkdir(path, unsafe { Mode::from_bits_unchecked(mode) }) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(nix::Error::Sys(Errno::EEXIST)) => Ok(()),
|
||||||
|
Err(err) => bail!("failed to create directory {:?}: {}", path, err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
#[derive(Clone)]
|
||||||
|
#[repr(transparent)]
|
||||||
|
pub struct UserAccess(perlmod::Value);
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
impl UserAccess {
|
||||||
|
#[inline]
|
||||||
|
fn new(value: &perlmod::Value) -> Result<Self, Error> {
|
||||||
|
value
|
||||||
|
.dereference()
|
||||||
|
.ok_or_else(|| format_err!("bad TFA config object"))
|
||||||
|
.map(Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn is_debug(&self) -> bool {
|
||||||
|
self.0
|
||||||
|
.as_hash()
|
||||||
|
.and_then(|v| v.get("-debug"))
|
||||||
|
.map(|v| v.iv() != 0)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
#[repr(transparent)]
|
||||||
|
pub struct UserAccess;
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
impl UserAccess {
|
||||||
|
#[inline]
|
||||||
|
const fn new(_value: &perlmod::Value) -> Result<Self, std::convert::Infallible> {
|
||||||
|
Ok(Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
const fn is_debug(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the path to the challenge data file for a user.
|
||||||
|
fn challenge_data_path(userid: &str, debug: bool) -> PathBuf {
|
||||||
|
if debug {
|
||||||
|
PathBuf::from(format!("./local-tfa-challenges/{}", userid))
|
||||||
|
} else {
|
||||||
|
PathBuf::from(format!("/run/pmg-private/tfa-challenges/{}", userid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl proxmox_tfa::api::OpenUserChallengeData for UserAccess {
|
||||||
|
type Data = UserChallengeData;
|
||||||
|
|
||||||
|
fn open(&self, userid: &str) -> Result<UserChallengeData, Error> {
|
||||||
|
if self.is_debug() {
|
||||||
|
mkdir("./local-tfa-challenges", 0o700)?;
|
||||||
|
} else {
|
||||||
|
mkdir("/run/pmg-private", 0o700)?;
|
||||||
|
mkdir("/run/pmg-private/tfa-challenges", 0o700)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = challenge_data_path(userid, self.is_debug());
|
||||||
|
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(false)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&path)
|
||||||
|
.map_err(|err| format_err!("failed to create challenge file {:?}: {}", &path, err))?;
|
||||||
|
|
||||||
|
UserChallengeData::lock_file(file.as_raw_fd())?;
|
||||||
|
|
||||||
|
// the file may be empty, so read to a temporary buffer first:
|
||||||
|
let mut data = Vec::with_capacity(4096);
|
||||||
|
|
||||||
|
file.read_to_end(&mut data).map_err(|err| {
|
||||||
|
format_err!("failed to read challenge data for user {}: {}", userid, err)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let inner = if data.is_empty() {
|
||||||
|
Default::default()
|
||||||
|
} else {
|
||||||
|
match serde_json::from_slice(&data) {
|
||||||
|
Ok(inner) => inner,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!(
|
||||||
|
"failed to parse challenge data for user {}: {}",
|
||||||
|
userid, err
|
||||||
|
);
|
||||||
|
Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(UserChallengeData {
|
||||||
|
inner,
|
||||||
|
path,
|
||||||
|
lock: file,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `open` without creating the file if it doesn't exist, to finish WA authentications.
|
||||||
|
fn open_no_create(&self, userid: &str) -> Result<Option<UserChallengeData>, Error> {
|
||||||
|
let path = challenge_data_path(userid, self.is_debug());
|
||||||
|
|
||||||
|
let mut file = match std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(false)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&path)
|
||||||
|
{
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
UserChallengeData::lock_file(file.as_raw_fd())?;
|
||||||
|
|
||||||
|
let inner = serde_json::from_reader(&mut file).map_err(|err| {
|
||||||
|
format_err!("failed to read challenge data for user {}: {}", userid, err)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Some(UserChallengeData {
|
||||||
|
inner,
|
||||||
|
path,
|
||||||
|
lock: file,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove(&self, userid: &str) -> Result<bool, Error> {
|
||||||
|
let path = challenge_data_path(userid, self.is_debug());
|
||||||
|
match std::fs::remove_file(&path) {
|
||||||
|
Ok(()) => Ok(true),
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Container of `TfaUserChallenges` with the corresponding file lock guard.
|
||||||
|
///
|
||||||
|
/// Basically provides the TFA API to the REST server by persisting, updating and verifying active
|
||||||
|
/// challenges.
|
||||||
|
pub struct UserChallengeData {
|
||||||
|
inner: proxmox_tfa::api::TfaUserChallenges,
|
||||||
|
path: PathBuf,
|
||||||
|
lock: File,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl proxmox_tfa::api::UserChallengeAccess for UserChallengeData {
|
||||||
|
fn get_mut(&mut self) -> &mut proxmox_tfa::api::TfaUserChallenges {
|
||||||
|
&mut self.inner
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(self) -> Result<(), Error> {
|
||||||
|
UserChallengeData::save(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserChallengeData {
|
||||||
|
fn lock_file(fd: RawFd) -> Result<(), Error> {
|
||||||
|
let rc = unsafe { libc::flock(fd, libc::LOCK_EX) };
|
||||||
|
|
||||||
|
if rc != 0 {
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
bail!("failed to lock tfa user challenge data: {}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewind & truncate the file for an update.
|
||||||
|
fn rewind(&mut self) -> Result<(), Error> {
|
||||||
|
use std::io::{Seek, SeekFrom};
|
||||||
|
|
||||||
|
let pos = self.lock.seek(SeekFrom::Start(0))?;
|
||||||
|
if pos != 0 {
|
||||||
|
bail!(
|
||||||
|
"unexpected result trying to rewind file, position is {}",
|
||||||
|
pos
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rc = unsafe { libc::ftruncate(self.lock.as_raw_fd(), 0) };
|
||||||
|
if rc != 0 {
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
bail!("failed to truncate challenge data: {}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the current data. Note that we do not replace the file here since we lock the file
|
||||||
|
/// itself, as it is in `/run`, and the typical error case for this particular situation
|
||||||
|
/// (machine loses power) simply prevents some login, but that'll probably fail anyway for
|
||||||
|
/// other reasons then...
|
||||||
|
///
|
||||||
|
/// This currently consumes selfe as we never perform more than 1 insertion/removal, and this
|
||||||
|
/// way also unlocks early.
|
||||||
|
fn save(mut self) -> Result<(), Error> {
|
||||||
|
self.rewind()?;
|
||||||
|
|
||||||
|
serde_json::to_writer(&mut &self.lock, &self.inner).map_err(|err| {
|
||||||
|
format_err!("failed to update challenge file {:?}: {}", self.path, err)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user