1 //! This implements the `tfa.cfg` parser & TFA API calls for PMG.
3 //! The exported `PMG::RS::TFA` perl package provides access to rust's `TfaConfig`.
4 //! Contrary to the PVE implementation, this does not need to provide any backward compatible
7 //! NOTE: In PMG the tfa config is behind `PVE::INotify`'s `ccache`, so PMG sets it to `noclone` in
8 //! order to avoid losing the rust magic-ref.
11 use std
::io
::{self, Read}
;
12 use std
::os
::unix
::fs
::OpenOptionsExt
;
13 use std
::os
::unix
::io
::{AsRawFd, RawFd}
;
14 use std
::path
::{Path, PathBuf}
;
16 use anyhow
::{bail, format_err, Error}
;
17 use nix
::errno
::Errno
;
18 use nix
::sys
::stat
::Mode
;
20 pub(self) use proxmox_tfa
::api
::{
21 RecoveryState
, TfaChallenge
, TfaConfig
, TfaResponse
, U2fConfig
, UserChallengeAccess
,
25 #[perlmod::package(name = "PMG::RS::TFA")]
27 use std
::collections
::HashMap
;
28 use std
::convert
::TryInto
;
31 use anyhow
::{bail, format_err, Error}
;
32 use serde_bytes
::ByteBuf
;
36 use proxmox_tfa
::api
::{methods, TfaResult}
;
38 use super::{TfaConfig, UserAccess}
;
40 perlmod
::declare_magic
!(Box
<Tfa
> : &Tfa
as "PMG::RS::TFA");
42 /// A TFA Config instance.
44 inner
: Mutex
<TfaConfig
>,
48 #[export(name = "STORABLE_freeze", raw_return)]
49 fn storable_freeze(#[try_from_ref] _this: &Tfa, _cloning: bool) -> Result<Value, Error> {
50 bail
!("freezing TFA config not supported!");
53 /// Parse a TFA configuration.
55 fn new(#[raw] class: Value, config: &[u8]) -> Result<Value, Error> {
56 let mut inner
: TfaConfig
= serde_json
::from_slice(config
)
57 .map_err(|err
| format_err
!("failed to parse TFA file: {}", err
))?
;
59 // PMG does not support U2F.
61 Ok(perlmod
::instantiate_magic
!(
62 &class
, MAGIC
=> Box
::new(Tfa { inner: Mutex::new(inner) }
)
66 /// Write the configuration out into a JSON string.
68 fn write(#[try_from_ref] this: &Tfa) -> Result<serde_bytes::ByteBuf, Error> {
69 let inner
= this
.inner
.lock().unwrap();
70 Ok(ByteBuf
::from(serde_json
::to_vec(&*inner
)?
))
73 /// Debug helper: serialize the TFA user data into a perl value.
75 fn to_perl(#[try_from_ref] this: &Tfa) -> Result<Value, Error> {
76 let inner
= this
.inner
.lock().unwrap();
77 Ok(perlmod
::to_value(&*inner
)?
)
80 /// Get a list of all the user names in this config.
81 /// PMG uses this to verify users and purge the invalid ones.
83 fn users(#[try_from_ref] this: &Tfa) -> Result<Vec<String>, Error> {
84 Ok(this
.inner
.lock().unwrap().users
.keys().cloned().collect())
87 /// Remove a user from the TFA configuration.
89 fn remove_user(#[try_from_ref] this: &Tfa, userid: &str) -> Result<bool, Error> {
90 Ok(this
.inner
.lock().unwrap().users
.remove(userid
).is_some())
93 /// Get the TFA data for a specific user.
95 fn get_user(#[try_from_ref] this: &Tfa, userid: &str) -> Result<Value, perlmod::Error> {
96 perlmod
::to_value(&this
.inner
.lock().unwrap().users
.get(userid
))
99 /// Add a u2f registration. This modifies the config (adds the user to it), so it needs be
102 fn add_u2f_registration(
103 #[raw] raw_this: Value,
104 //#[try_from_ref] this: &Tfa,
107 ) -> Result
<String
, Error
> {
108 let this
: &Tfa
= (&raw_this
).try_into()?
;
109 let mut inner
= this
.inner
.lock().unwrap();
110 inner
.u2f_registration_challenge(&UserAccess
::new(&raw_this
)?
, userid
, description
)
113 /// Finish a u2f registration. This updates temporary data in `/run` and therefore the config
114 /// needs to be written out!
116 fn finish_u2f_registration(
117 #[raw] raw_this: Value,
118 //#[try_from_ref] this: &Tfa,
122 ) -> Result
<String
, Error
> {
123 let this
: &Tfa
= (&raw_this
).try_into()?
;
124 let mut inner
= this
.inner
.lock().unwrap();
125 inner
.u2f_registration_finish(&UserAccess
::new(&raw_this
)?
, userid
, challenge
, response
)
128 /// Check if a user has any TFA entries of a given type.
130 fn has_type(#[try_from_ref] this: &Tfa, userid: &str, typename: &str) -> Result<bool, Error> {
131 Ok(match this
.inner
.lock().unwrap().users
.get(userid
) {
132 Some(user
) => match typename
{
133 "totp" | "oath" => !user
.totp
.is_empty(),
134 "u2f" => !user
.u2f
.is_empty(),
135 "webauthn" => !user
.webauthn
.is_empty(),
136 "yubico" => !user
.yubico
.is_empty(),
137 "recovery" => match &user
.recovery
{
138 Some(r
) => r
.count_available() > 0,
141 _
=> bail
!("unrecognized TFA type {:?}", typename
),
147 /// Generates a space separated list of yubico keys of this account.
149 fn get_yubico_keys(#[try_from_ref] this: &Tfa, userid: &str) -> Result<Option<String>, Error> {
150 Ok(this
.inner
.lock().unwrap().users
.get(userid
).map(|user
| {
151 user
.enabled_yubico_entries()
152 .fold(String
::new(), |mut s
, k
| {
163 fn set_u2f_config(#[try_from_ref] this: &Tfa, config: Option<super::U2fConfig>) {
164 this
.inner
.lock().unwrap().u2f
= config
;
168 fn set_webauthn_config(
169 #[try_from_ref] this: &Tfa,
170 config
: Option
<super::WebauthnConfig
>,
171 ) -> Result
<(), Error
> {
172 this
.inner
.lock().unwrap().webauthn
= config
.map(TryInto
::try_into
).transpose()?
;
177 fn get_webauthn_config(
178 #[try_from_ref] this: &Tfa,
179 ) -> Result
<(Option
<String
>, Option
<super::WebauthnConfig
>), Error
> {
180 Ok(match this
.inner
.lock().unwrap().webauthn
.clone() {
181 Some(config
) => (Some(hex
::encode(&config
.digest())), Some(config
.into())),
182 None
=> (None
, None
),
187 fn has_webauthn_origin(#[try_from_ref] this: &Tfa) -> bool {
188 match &this
.inner
.lock().unwrap().webauthn
{
189 Some(wa
) => wa
.origin
.is_some(),
194 /// Create an authentication challenge.
196 /// Returns the challenge as a json string.
197 /// Returns `undef` if no second factor is configured.
199 fn authentication_challenge(
200 #[raw] raw_this: Value,
201 //#[try_from_ref] this: &Tfa,
204 ) -> Result
<Option
<String
>, Error
> {
205 let this
: &Tfa
= (&raw_this
).try_into()?
;
206 let mut inner
= this
.inner
.lock().unwrap();
207 match inner
.authentication_challenge(
208 &UserAccess
::new(&raw_this
)?
,
212 Some(challenge
) => Ok(Some(serde_json
::to_string(&challenge
)?
)),
217 /// Get the recovery state (suitable for a challenge object).
219 fn recovery_state(#[try_from_ref] this: &Tfa, userid: &str) -> Option<super::RecoveryState> {
225 .and_then(|user
| user
.recovery_state())
228 /// Takes the TFA challenge string (which is a json object) and verifies ther esponse against
231 /// NOTE: This returns a boolean whether the config data needs to be *saved* after this call
232 /// (to use up recovery keys!).
234 fn authentication_verify(
235 #[raw] raw_this: Value,
236 //#[try_from_ref] this: &Tfa,
238 challenge
: &str, //super::TfaChallenge,
241 ) -> Result
<bool
, Error
> {
242 let this
: &Tfa
= (&raw_this
).try_into()?
;
243 let challenge
: super::TfaChallenge
= serde_json
::from_str(challenge
)?
;
244 let response
: super::TfaResponse
= response
.parse()?
;
245 let mut inner
= this
.inner
.lock().unwrap();
246 let result
= inner
.verify(
247 &UserAccess
::new(&raw_this
)?
,
254 TfaResult
::Success { needs_saving }
=> Ok(needs_saving
),
255 _
=> bail
!("TFA authentication failed"),
259 /// Takes the TFA challenge string (which is a json object) and verifies ther esponse against
262 /// Returns a result hash of the form:
265 /// "result": bool, // whether TFA was successful
266 /// "needs-saving": bool, // whether the user config needs saving
267 /// "tfa-limit-reached": bool, // whether the TFA limit was reached (config needs saving)
268 /// "totp-limit-reached": bool, // whether the TOTP limit was reached (config needs saving)
272 fn authentication_verify2(
273 #[raw] raw_this: Value,
274 //#[try_from_ref] this: &Tfa,
276 challenge
: &str, //super::TfaChallenge,
279 ) -> Result
<TfaReturnValue
, Error
> {
280 let this
: &Tfa
= (&raw_this
).try_into()?
;
281 let challenge
: super::TfaChallenge
= serde_json
::from_str(challenge
)?
;
282 let response
: super::TfaResponse
= response
.parse()?
;
283 let mut inner
= this
.inner
.lock().unwrap();
284 let result
= inner
.verify(
285 &UserAccess
::new(&raw_this
)?
,
292 TfaResult
::Success { needs_saving }
=> TfaReturnValue
{
297 TfaResult
::Locked
=> TfaReturnValue
::default(),
302 } => TfaReturnValue
{
311 #[derive(Default, serde::Serialize)]
312 #[serde(rename_all = "kebab-case")]
313 struct TfaReturnValue
{
316 totp_limit_reached
: bool
,
317 tfa_limit_reached
: bool
,
320 /// DEBUG HELPER: Get the current TOTP value for a given TOTP URI.
322 fn get_current_totp_value(otp_uri
: &str) -> Result
<String
, Error
> {
323 let totp
: proxmox_tfa
::totp
::Totp
= otp_uri
.parse()?
;
324 Ok(totp
.time(std
::time
::SystemTime
::now())?
.to_string())
328 fn api_list_user_tfa(
329 #[try_from_ref] this: &Tfa,
331 ) -> Result
<Vec
<methods
::TypedTfaInfo
>, Error
> {
332 methods
::list_user_tfa(&this
.inner
.lock().unwrap(), userid
)
336 fn api_get_tfa_entry(
337 #[try_from_ref] this: &Tfa,
340 ) -> Option
<methods
::TypedTfaInfo
> {
341 methods
::get_tfa_entry(&this
.inner
.lock().unwrap(), userid
, id
)
344 /// Returns `true` if the user still has other TFA entries left, `false` if the user has *no*
345 /// more tfa entries.
347 fn api_delete_tfa(#[try_from_ref] this: &Tfa, userid: &str, id: String) -> Result<bool, Error> {
348 let mut this
= this
.inner
.lock().unwrap();
349 match methods
::delete_tfa(&mut this
, userid
, &id
) {
350 Ok(has_entries_left
) => Ok(has_entries_left
),
351 Err(methods
::EntryNotFound
) => bail
!("no such entry"),
357 #[try_from_ref] this: &Tfa,
359 top_level_allowed
: bool
,
360 ) -> Result
<Vec
<methods
::TfaUser
>, Error
> {
361 methods
::list_tfa(&this
.inner
.lock().unwrap(), authid
, top_level_allowed
)
365 fn api_add_tfa_entry(
366 #[raw] raw_this: Value,
367 //#[try_from_ref] this: &Tfa,
369 description
: Option
<String
>,
370 totp
: Option
<String
>,
371 value
: Option
<String
>,
372 challenge
: Option
<String
>,
373 ty
: methods
::TfaType
,
375 ) -> Result
<methods
::TfaUpdateInfo
, Error
> {
376 let this
: &Tfa
= (&raw_this
).try_into()?
;
377 methods
::add_tfa_entry(
378 &mut this
.inner
.lock().unwrap(),
379 &UserAccess
::new(&raw_this
)?
,
390 /// Add a totp entry without validating it, used for user.cfg keys.
394 #[try_from_ref] this: &Tfa,
398 ) -> Result
<String
, Error
> {
403 .add_totp(userid
, description
, totp
.parse()?
))
406 /// Add a yubico entry without validating it, used for user.cfg keys.
410 #[try_from_ref] this: &Tfa,
418 .add_yubico(userid
, description
, yubico
)
422 fn api_update_tfa_entry(
423 #[try_from_ref] this: &Tfa,
426 description
: Option
<String
>,
427 enable
: Option
<bool
>,
428 ) -> Result
<(), Error
> {
429 match methods
::update_tfa_entry(
430 &mut this
.inner
.lock().unwrap(),
437 Err(methods
::EntryNotFound
) => bail
!("no such entry"),
442 fn api_unlock_tfa(#[raw] raw_this: Value, userid: &str) -> Result<bool, Error> {
443 let this
: &Tfa
= (&raw_this
).try_into()?
;
444 Ok(methods
::unlock_and_reset_tfa(
445 &mut this
.inner
.lock().unwrap(),
446 &UserAccess
::new(&raw_this
)?
,
451 #[derive(serde::Serialize)]
452 #[serde(rename_all = "kebab-case")]
453 struct TfaLockStatus
{
454 /// Once a user runs into a TOTP limit they get locked out of TOTP until they successfully use
456 #[serde(skip_serializing_if = "bool_is_false", default)]
459 /// If a user hits too many 2nd factor failures, they get completely blocked for a while.
460 #[serde(skip_serializing_if = "Option::is_none", default)]
461 #[serde(deserialize_with = "filter_expired_timestamp")]
462 tfa_locked_until
: Option
<i64>,
465 impl From
<&proxmox_tfa
::api
::TfaUserData
> for TfaLockStatus
{
466 fn from(data
: &proxmox_tfa
::api
::TfaUserData
) -> Self {
468 totp_locked
: data
.totp_locked
,
469 tfa_locked_until
: data
.tfa_locked_until
,
474 fn bool_is_false(b
: &bool
) -> bool
{
480 #[try_from_ref] this: &Tfa,
481 userid
: Option
<&str>,
482 ) -> Result
<Option
<perlmod
::Value
>, Error
> {
483 let this
= this
.inner
.lock().unwrap();
484 if let Some(userid
) = userid
{
485 if let Some(user
) = this
.users
.get(userid
) {
486 Ok(Some(perlmod
::to_value(&TfaLockStatus
::from(user
))?
))
491 Ok(Some(perlmod
::to_value(
492 &HashMap
::<String
, TfaLockStatus
>::from_iter(
495 .map(|(uid
, data
)| (uid
.clone(), TfaLockStatus
::from(data
))),
502 /// Attach the path to errors from [`nix::mkir()`].
503 pub(crate) fn mkdir
<P
: AsRef
<Path
>>(path
: P
, mode
: libc
::mode_t
) -> Result
<(), Error
> {
504 let path
= path
.as_ref();
505 match nix
::unistd
::mkdir(path
, unsafe { Mode::from_bits_unchecked(mode) }
) {
507 Err(Errno
::EEXIST
) => Ok(()),
508 Err(err
) => bail
!("failed to create directory {:?}: {}", path
, err
),
512 #[cfg(debug_assertions)]
515 pub struct UserAccess(perlmod
::Value
);
517 #[cfg(debug_assertions)]
520 fn new(value
: &perlmod
::Value
) -> Result
<Self, Error
> {
523 .ok_or_else(|| format_err
!("bad TFA config object"))
528 fn is_debug(&self) -> bool
{
531 .and_then(|v
| v
.get("-debug"))
532 .map(|v
| v
.iv() != 0)
537 #[cfg(not(debug_assertions))]
538 #[derive(Clone, Copy)]
540 pub struct UserAccess
;
542 #[cfg(not(debug_assertions))]
545 const fn new(_value
: &perlmod
::Value
) -> Result
<Self, std
::convert
::Infallible
> {
550 const fn is_debug(&self) -> bool
{
555 /// Build the path to the challenge data file for a user.
556 fn challenge_data_path(userid
: &str, debug
: bool
) -> PathBuf
{
558 PathBuf
::from(format
!("./local-tfa-challenges/{}", userid
))
560 PathBuf
::from(format
!("/run/pmg-private/tfa-challenges/{}", userid
))
564 impl proxmox_tfa
::api
::OpenUserChallengeData
for UserAccess
{
565 fn open(&self, userid
: &str) -> Result
<Box
<dyn UserChallengeAccess
>, Error
> {
567 mkdir("./local-tfa-challenges", 0o700)?
;
569 mkdir("/run/pmg-private", 0o700)?
;
570 mkdir("/run/pmg-private/tfa-challenges", 0o700)?
;
573 let path
= challenge_data_path(userid
, self.is_debug());
575 let mut file
= std
::fs
::OpenOptions
::new()
582 .map_err(|err
| format_err
!("failed to create challenge file {:?}: {}", &path
, err
))?
;
584 UserChallengeData
::lock_file(file
.as_raw_fd())?
;
586 // the file may be empty, so read to a temporary buffer first:
587 let mut data
= Vec
::with_capacity(4096);
589 file
.read_to_end(&mut data
).map_err(|err
| {
590 format_err
!("failed to read challenge data for user {}: {}", userid
, err
)
593 let inner
= if data
.is_empty() {
596 match serde_json
::from_slice(&data
) {
600 "failed to parse challenge data for user {}: {}",
608 Ok(Box
::new(UserChallengeData
{
615 /// `open` without creating the file if it doesn't exist, to finish WA authentications.
616 fn open_no_create(&self, userid
: &str) -> Result
<Option
<Box
<dyn UserChallengeAccess
>>, Error
> {
617 let path
= challenge_data_path(userid
, self.is_debug());
619 let mut file
= match std
::fs
::OpenOptions
::new()
627 Err(err
) if err
.kind() == io
::ErrorKind
::NotFound
=> return Ok(None
),
628 Err(err
) => return Err(err
.into()),
631 UserChallengeData
::lock_file(file
.as_raw_fd())?
;
633 let inner
= serde_json
::from_reader(&mut file
).map_err(|err
| {
634 format_err
!("failed to read challenge data for user {}: {}", userid
, err
)
637 Ok(Some(Box
::new(UserChallengeData
{
644 fn remove(&self, userid
: &str) -> Result
<bool
, Error
> {
645 let path
= challenge_data_path(userid
, self.is_debug());
646 match std
::fs
::remove_file(&path
) {
648 Err(err
) if err
.kind() == io
::ErrorKind
::NotFound
=> Ok(false),
649 Err(err
) => Err(err
.into()),
653 fn enable_lockout(&self) -> bool
{
658 /// Container of `TfaUserChallenges` with the corresponding file lock guard.
660 /// Basically provides the TFA API to the REST server by persisting, updating and verifying active
662 pub struct UserChallengeData
{
663 inner
: proxmox_tfa
::api
::TfaUserChallenges
,
668 impl proxmox_tfa
::api
::UserChallengeAccess
for UserChallengeData
{
669 fn get_mut(&mut self) -> &mut proxmox_tfa
::api
::TfaUserChallenges
{
673 fn save(&mut self) -> Result
<(), Error
> {
674 UserChallengeData
::save(self)
678 impl UserChallengeData
{
679 fn lock_file(fd
: RawFd
) -> Result
<(), Error
> {
680 let rc
= unsafe { libc::flock(fd, libc::LOCK_EX) }
;
683 let err
= io
::Error
::last_os_error();
684 bail
!("failed to lock tfa user challenge data: {}", err
);
690 /// Rewind & truncate the file for an update.
691 fn rewind(&mut self) -> Result
<(), Error
> {
692 use std
::io
::{Seek, SeekFrom}
;
694 let pos
= self.lock
.seek(SeekFrom
::Start(0))?
;
697 "unexpected result trying to rewind file, position is {}",
702 let rc
= unsafe { libc::ftruncate(self.lock.as_raw_fd(), 0) }
;
704 let err
= io
::Error
::last_os_error();
705 bail
!("failed to truncate challenge data: {}", err
);
711 /// Save the current data. Note that we do not replace the file here since we lock the file
712 /// itself, as it is in `/run`, and the typical error case for this particular situation
713 /// (machine loses power) simply prevents some login, but that'll probably fail anyway for
714 /// other reasons then...
716 /// This currently consumes selfe as we never perform more than 1 insertion/removal, and this
717 /// way also unlocks early.
718 fn save(&mut self) -> Result
<(), Error
> {
721 serde_json
::to_writer(&mut &self.lock
, &self.inner
).map_err(|err
| {
722 format_err
!("failed to update challenge file {:?}: {}", self.path
, err
)