tfa: don't automatically drop empty recovery

This should only ever be explicitly removed.

Similarly, include an empty array of recovery keys in the
tfa challenge, so that clients know about empty recoveries
rather than getting an empty challenge when there are no
other factors available.

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
This commit is contained in:
Wolfgang Bumiller 2023-04-18 10:39:16 +02:00
parent b66ceaede0
commit ea1d023a61
3 changed files with 10 additions and 34 deletions

View File

@ -52,9 +52,9 @@ fn to_data(data: &TfaUserData) -> Vec<TypedTfaInfo> {
+ data.u2f.len() + data.u2f.len()
+ data.webauthn.len() + data.webauthn.len()
+ data.yubico.len() + data.yubico.len()
+ if data.recovery().is_some() { 1 } else { 0 }, + if data.recovery.is_some() { 1 } else { 0 },
); );
if let Some(recovery) = data.recovery() { if let Some(recovery) = &data.recovery {
out.push(TypedTfaInfo { out.push(TypedTfaInfo {
ty: TfaType::Recovery, ty: TfaType::Recovery,
info: TfaInfo::recovery(recovery.created), info: TfaInfo::recovery(recovery.created),
@ -145,7 +145,7 @@ pub fn get_tfa_entry(config: &TfaConfig, userid: &str, id: &str) -> Option<Typed
let entry = tfa_id_iter(user_data).find(|(_ty, _index, entry_id)| id == *entry_id); let entry = tfa_id_iter(user_data).find(|(_ty, _index, entry_id)| id == *entry_id);
entry.map(|(ty, index, _)| (ty, index)) entry.map(|(ty, index, _)| (ty, index))
} { } {
Some((TfaType::Recovery, _)) => match user_data.recovery() { Some((TfaType::Recovery, _)) => match &user_data.recovery {
Some(recovery) => TypedTfaInfo { Some(recovery) => TypedTfaInfo {
ty: TfaType::Recovery, ty: TfaType::Recovery,
info: TfaInfo::recovery(recovery.created), info: TfaInfo::recovery(recovery.created),

View File

@ -320,7 +320,7 @@ pub struct TfaUserData {
pub webauthn: Vec<TfaEntry<WebauthnCredential>>, pub webauthn: Vec<TfaEntry<WebauthnCredential>>,
/// Recovery keys. (Unordered OTP values). /// Recovery keys. (Unordered OTP values).
#[serde(skip_serializing_if = "Recovery::option_is_empty", default)] #[serde(skip_serializing_if = "Option::is_none", default)]
pub recovery: Option<Recovery>, pub recovery: Option<Recovery>,
/// Yubico keys for a user. NOTE: This is not directly supported currently, we just need this /// Yubico keys for a user. NOTE: This is not directly supported currently, we just need this
@ -330,22 +330,13 @@ pub struct TfaUserData {
} }
impl TfaUserData { impl TfaUserData {
/// Shortcut to get the recovery entry only if it is not empty!
pub fn recovery(&self) -> Option<&Recovery> {
if Recovery::option_is_empty(&self.recovery) {
None
} else {
self.recovery.as_ref()
}
}
/// `true` if no second factors exist /// `true` if no second factors exist
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.totp.is_empty() self.totp.is_empty()
&& self.u2f.is_empty() && self.u2f.is_empty()
&& self.webauthn.is_empty() && self.webauthn.is_empty()
&& self.yubico.is_empty() && self.yubico.is_empty()
&& self.recovery().is_none() && self.recovery.is_none()
} }
/// Find an entry by id, except for the "recovery" entry which we're currently treating /// Find an entry by id, except for the "recovery" entry which we're currently treating
@ -577,7 +568,7 @@ impl TfaUserData {
Ok(Some(TfaChallenge { Ok(Some(TfaChallenge {
totp: self.totp.iter().any(|e| e.info.enable), totp: self.totp.iter().any(|e| e.info.enable),
recovery: RecoveryState::from(&self.recovery), recovery: self.recovery_state(),
webauthn: match webauthn { webauthn: match webauthn {
Some(webauthn) => self.webauthn_challenge(access, userid, webauthn?)?, Some(webauthn) => self.webauthn_challenge(access, userid, webauthn?)?,
None => None, None => None,
@ -591,8 +582,8 @@ impl TfaUserData {
} }
/// Get the recovery state. /// Get the recovery state.
pub fn recovery_state(&self) -> RecoveryState { pub fn recovery_state(&self) -> Option<RecoveryState> {
RecoveryState::from(&self.recovery) self.recovery.as_ref().map(RecoveryState::from)
} }
/// Generate an optional webauthn challenge. /// Generate an optional webauthn challenge.
@ -855,8 +846,8 @@ pub struct TfaChallenge {
pub totp: bool, pub totp: bool,
/// Whether there are recovery keys available. /// Whether there are recovery keys available.
#[serde(skip_serializing_if = "RecoveryState::is_unavailable", default)] #[serde(skip_serializing_if = "Option::is_none", default)]
pub recovery: RecoveryState, pub recovery: Option<RecoveryState>,
/// If the user has any u2f tokens registered, this will contain the U2F challenge data. /// If the user has any u2f tokens registered, this will contain the U2F challenge data.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]

View File

@ -96,12 +96,6 @@ impl Recovery {
self.available().count() self.available().count()
} }
/// Convenience serde method to check if either the option is `None` or the content `is_empty`.
pub(super) fn option_is_empty(this: &Option<Self>) -> bool {
this.as_ref()
.map_or(true, |this| this.count_available() == 0)
}
/// Verify a key and remove it. Returns whether the key was valid. Errors on openssl errors. /// Verify a key and remove it. Returns whether the key was valid. Errors on openssl errors.
pub(super) fn verify(&mut self, key: &str) -> Result<bool, Error> { pub(super) fn verify(&mut self, key: &str) -> Result<bool, Error> {
let hash = self.hash(key.as_bytes())?; let hash = self.hash(key.as_bytes())?;
@ -131,15 +125,6 @@ impl RecoveryState {
} }
} }
impl From<&Option<Recovery>> for RecoveryState {
fn from(r: &Option<Recovery>) -> Self {
match r {
Some(r) => Self::from(r),
None => Self::default(),
}
}
}
impl From<&Recovery> for RecoveryState { impl From<&Recovery> for RecoveryState {
fn from(r: &Recovery) -> Self { fn from(r: &Recovery) -> Self {
Self( Self(