diff --git a/kanidm_client/src/lib.rs b/kanidm_client/src/lib.rs index 8c4007a60..52c95d8b4 100644 --- a/kanidm_client/src/lib.rs +++ b/kanidm_client/src/lib.rs @@ -1468,8 +1468,9 @@ impl KanidmClient { &self, session_token: &CUSessionToken, totp_chal: u32, + label: &str, ) -> Result { - let scr = CURequest::TotpVerify(totp_chal); + let scr = CURequest::TotpVerify(totp_chal, label.to_string()); self.perform_simple_post_request("/v1/credential/_update", &(scr, &session_token)) .await } @@ -1486,8 +1487,9 @@ impl KanidmClient { pub async fn idm_account_credential_update_remove_totp( &self, session_token: &CUSessionToken, + label: &str, ) -> Result { - let scr = CURequest::TotpRemove; + let scr = CURequest::TotpRemove(label.to_string()); self.perform_simple_post_request("/v1/credential/_update", &(scr, &session_token)) .await } diff --git a/kanidm_proto/src/v1.rs b/kanidm_proto/src/v1.rs index 11becec01..a8ab19093 100644 --- a/kanidm_proto/src/v1.rs +++ b/kanidm_proto/src/v1.rs @@ -643,7 +643,7 @@ pub enum CredentialDetailType { GeneratedPassword, Passkey(Vec), /// totp, webauthn - PasswordMfa(bool, Vec, usize), + PasswordMfa(Vec, Vec, usize), } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -675,24 +675,30 @@ impl fmt::Display for CredentialDetail { write!(f, "") } } - CredentialDetailType::PasswordMfa(totp, labels, backup_code) => { + CredentialDetailType::PasswordMfa(totp_labels, wan_labels, backup_code) => { writeln!(f, "password: set")?; - if *totp { - writeln!(f, "totp: enabled")?; + + if !totp_labels.is_empty() { + writeln!(f, "totp:")?; + for label in totp_labels { + writeln!(f, " * {}", label)?; + } } else { writeln!(f, "totp: disabled")?; } + if *backup_code > 0 { writeln!(f, "backup_code: enabled")?; } else { writeln!(f, "backup_code: disabled")?; } - if !labels.is_empty() { + + if !wan_labels.is_empty() { // We no longer show the deprecated security key case by default. writeln!(f, " ⚠️ warning - security keys are deprecated.")?; writeln!(f, " ⚠️ you should re-enroll these to passkeys.")?; writeln!(f, "security keys:")?; - for label in labels { + for label in wan_labels { writeln!(f, " * {}", label)?; } write!(f, "") @@ -1123,9 +1129,9 @@ pub enum CURequest { Password(String), CancelMFAReg, TotpGenerate, - TotpVerify(u32), + TotpVerify(u32, String), TotpAcceptSha1, - TotpRemove, + TotpRemove(String), BackupCodeGenerate, BackupCodeRemove, PasskeyInit, @@ -1140,9 +1146,9 @@ impl fmt::Debug for CURequest { CURequest::Password(_) => "CURequest::Password", CURequest::CancelMFAReg => "CURequest::CancelMFAReg", CURequest::TotpGenerate => "CURequest::TotpGenerate", - CURequest::TotpVerify(_) => "CURequest::TotpVerify", + CURequest::TotpVerify(_, _) => "CURequest::TotpVerify", CURequest::TotpAcceptSha1 => "CURequest::TotpAcceptSha1", - CURequest::TotpRemove => "CURequest::TotpRemove", + CURequest::TotpRemove(_) => "CURequest::TotpRemove", CURequest::BackupCodeGenerate => "CURequest::BackupCodeGenerate", CURequest::BackupCodeRemove => "CURequest::BackupCodeRemove", CURequest::PasskeyInit => "CURequest::PasskeyInit", diff --git a/kanidm_tools/src/cli/person.rs b/kanidm_tools/src/cli/person.rs index a831a83c7..bb645d680 100644 --- a/kanidm_tools/src/cli/person.rs +++ b/kanidm_tools/src/cli/person.rs @@ -8,6 +8,7 @@ use kanidm_client::KanidmClient; use kanidm_proto::messages::{AccountChangeMessage, ConsoleOutputMode, MessageStatus}; use kanidm_proto::v1::OperationError::PasswordQuality; use kanidm_proto::v1::{CUIntentToken, CURegState, CUSessionToken, CUStatus, TotpSecret}; +use kanidm_proto::v1::{CredentialDetail, CredentialDetailType}; use qrcode::render::unicode; use qrcode::QrCode; use time::OffsetDateTime; @@ -577,7 +578,7 @@ impl AccountCredential { println!(); println!("This link: {}", url.as_str()); println!( - "Or run this command: kanidm account credential use_reset_token {}", + "Or run this command: kanidm person credential use_reset_token {}", cuintent_token.token ); println!(); @@ -672,6 +673,11 @@ async fn totp_enroll_prompt(session_token: &CUSessionToken, client: &KanidmClien } }; + let label: String = Input::new() + .with_prompt("TOTP Label") + .interact_text() + .expect("Failed to interact with interactive session"); + // gen the qr println!("Scan the following QR code with your OTP app."); @@ -739,7 +745,7 @@ async fn totp_enroll_prompt(session_token: &CUSessionToken, client: &KanidmClien // Submit and see what we get. match client - .idm_account_credential_update_check_totp(session_token, totp_chal) + .idm_account_credential_update_check_totp(session_token, totp_chal, &label) .await { Ok(CUStatus { @@ -978,13 +984,47 @@ async fn credential_update_exec( } CUAction::Totp => totp_enroll_prompt(&session_token, &client).await, CUAction::TotpRemove => { - if Confirm::new() - .with_prompt("Do you want to remove your totp?") - .interact() - .expect("Failed to interact with interactive session") + match client + .idm_account_credential_update_status(&session_token) + .await { + Ok(status) => match status.primary { + Some(CredentialDetail { + uuid: _, + type_: CredentialDetailType::PasswordMfa(totp_labels, ..), + }) => { + if totp_labels.is_empty() { + println!("No totps are configured for this user"); + return; + } else { + println!("Current totps:"); + for totp_label in totp_labels { + println!(" {}", totp_label); + } + } + } + _ => { + println!("No totps are configured for this user"); + return; + } + }, + Err(e) => { + eprintln!( + "An error occurred retrieving existing credentials -> {:?}", + e + ); + } + } + + let label: String = Input::new() + .with_prompt("\nEnter the label of the Passkey to remove (blank to stop) # ") + .allow_empty(true) + .interact_text() + .expect("Failed to interact with interactive session"); + + if !label.is_empty() { if let Err(e) = client - .idm_account_credential_update_remove_totp(&session_token) + .idm_account_credential_update_remove_totp(&session_token, &label) .await { eprintln!("An error occurred -> {:?}", e); @@ -1055,9 +1095,13 @@ async fn credential_update_exec( } } Err(e) => { - eprintln!("An error occurred pulling existing credentials -> {:?}", e); + eprintln!( + "An error occurred retrieving existing credentials -> {:?}", + e + ); } } + let uuid_s: String = Input::new() .with_prompt("\nEnter the UUID of the Passkey to remove (blank to stop) # ") .validate_with(|input: &String| -> Result<(), &str> { diff --git a/kanidmd/core/src/actors/v1_read.rs b/kanidmd/core/src/actors/v1_read.rs index c8077340f..166f3db1e 100644 --- a/kanidmd/core/src/actors/v1_read.rs +++ b/kanidmd/core/src/actors/v1_read.rs @@ -1053,8 +1053,8 @@ impl QueryServerReadV1 { ); e }), - CURequest::TotpVerify(totp_chal) => idms_cred_update - .credential_primary_check_totp(&session_token, ct, totp_chal) + CURequest::TotpVerify(totp_chal, label) => idms_cred_update + .credential_primary_check_totp(&session_token, ct, totp_chal, &label) .map_err(|e| { admin_error!( err = ?e, @@ -1071,8 +1071,8 @@ impl QueryServerReadV1 { ); e }), - CURequest::TotpRemove => idms_cred_update - .credential_primary_remove_totp(&session_token, ct) + CURequest::TotpRemove(label) => idms_cred_update + .credential_primary_remove_totp(&session_token, ct, &label) .map_err(|e| { admin_error!( err = ?e, diff --git a/kanidmd/lib/src/be/dbvalue.rs b/kanidmd/lib/src/be/dbvalue.rs index 9654b8c0b..59b7d93bc 100644 --- a/kanidmd/lib/src/be/dbvalue.rs +++ b/kanidmd/lib/src/be/dbvalue.rs @@ -176,10 +176,6 @@ pub enum DbCred { uuid: Uuid, }, - #[serde(rename = "V2Pw")] - V2Password { password: DbPasswordV1, uuid: Uuid }, - #[serde(rename = "V2GPw")] - V2GenPassword { password: DbPasswordV1, uuid: Uuid }, #[serde(rename = "V2PwMfa")] V2PasswordMfa { password: DbPasswordV1, @@ -188,6 +184,20 @@ pub enum DbCred { webauthn: Vec<(String, SecurityKeyV4)>, uuid: Uuid, }, + + // New Formats! + #[serde(rename = "V2Pw")] + V2Password { password: DbPasswordV1, uuid: Uuid }, + #[serde(rename = "V2GPw")] + V2GenPassword { password: DbPasswordV1, uuid: Uuid }, + #[serde(rename = "V3PwMfa")] + V3PasswordMfa { + password: DbPasswordV1, + totp: Vec<(String, DbTotpV1)>, + backup_code: Option, + webauthn: Vec<(String, SecurityKeyV4)>, + uuid: Uuid, + }, } impl fmt::Display for DbCred { @@ -280,6 +290,20 @@ impl fmt::Display for DbCred { backup_code.is_some(), uuid ), + DbCred::V3PasswordMfa { + password: _, + totp, + backup_code, + webauthn, + uuid, + } => write!( + f, + "V3PwMfa (p true, w {}, t {}, b {}, u {})", + webauthn.len(), + totp.len(), + backup_code.is_some(), + uuid + ), } } } diff --git a/kanidmd/lib/src/constants/entries.rs b/kanidmd/lib/src/constants/entries.rs index ed4cb141e..10de463ee 100644 --- a/kanidmd/lib/src/constants/entries.rs +++ b/kanidmd/lib/src/constants/entries.rs @@ -565,7 +565,7 @@ pub const JSON_SYSTEM_INFO_V1: &str = r#"{ "class": ["object", "system_info", "system"], "uuid": ["00000000-0000-0000-0000-ffffff000001"], "description": ["System (local) info and metadata object."], - "version": ["9"] + "version": ["10"] } }"#; @@ -574,8 +574,7 @@ pub const JSON_DOMAIN_INFO_V1: &str = r#"{ "class": ["object", "domain_info", "system"], "name": ["domain_local"], "uuid": ["00000000-0000-0000-0000-ffffff000025"], - "description": ["This local domain's info and metadata object."], - "version": ["1"] + "description": ["This local domain's info and metadata object."] } }"#; diff --git a/kanidmd/lib/src/constants/mod.rs b/kanidmd/lib/src/constants/mod.rs index 43ccd16f7..276e3dc41 100644 --- a/kanidmd/lib/src/constants/mod.rs +++ b/kanidmd/lib/src/constants/mod.rs @@ -18,6 +18,35 @@ use std::time::Duration; // Increment this as we add new schema types and values!!! pub const SYSTEM_INDEX_VERSION: i64 = 27; + +/* + * domain functional levels + * + * The idea here is to allow topology wide upgrades to be performed. We have to + * assume that across multiple kanidm instances there may be cases where we have version + * N and version N minus 1 as upgrades are rolled out. + * + * Imagine we set up a new cluster. Machine A and B both have level 1 support. + * We upgrade machine A. It has support up to level 2, but machine B does not. + * So the overall functional level is level 1. Then we upgrade B, which supports + * up to level 2. We still don't do the upgrade! The topology is still level 1 + * unless an admin at this point *intervenes* and forces the update. OR what + * happens we we update machine A again and it now supports up to level 3, with + * a target level of 2. So we update machine A now to level 2, and that can + * still replicate to machine B since it also supports level 2. + * + * effectively it means that "some features" may be a "release behind" for users + * who don't muck with the levels, but it means that we can do mixed version + * upgrades. + */ +pub const DOMAIN_LEVEL_1: u32 = 1; +// The minimum supported domain functional level +pub const DOMAIN_MIN_LEVEL: u32 = DOMAIN_LEVEL_1; +// The target supported domain functional level +pub const DOMAIN_TGT_LEVEL: u32 = DOMAIN_LEVEL_1; +// The maximum supported domain functional level +pub const DOMAIN_MAX_LEVEL: u32 = DOMAIN_LEVEL_1; + // On test builds, define to 60 seconds #[cfg(test)] pub const PURGE_FREQUENCY: u64 = 60; diff --git a/kanidmd/lib/src/constants/schema.rs b/kanidmd/lib/src/constants/schema.rs index 56ef229cc..556bdbc99 100644 --- a/kanidmd/lib/src/constants/schema.rs +++ b/kanidmd/lib/src/constants/schema.rs @@ -114,7 +114,9 @@ pub const JSON_SCHEMA_ATTR_PRIMARY_CREDENTIAL: &str = r#" "description": [ "Primary credential material of the account for authentication interactively." ], - "index": [], + "index": [ + "PRESENCE" + ], "unique": [ "false" ], @@ -485,7 +487,9 @@ pub const JSON_SCHEMA_ATTR_UNIX_PASSWORD: &str = r#"{ "description": [ "A posix users unix login password." ], - "index": [], + "index": [ + "PRESENCE" + ], "unique": [ "false" ], diff --git a/kanidmd/lib/src/credential/mod.rs b/kanidmd/lib/src/credential/mod.rs index 4d3168237..eabed0b19 100644 --- a/kanidmd/lib/src/credential/mod.rs +++ b/kanidmd/lib/src/credential/mod.rs @@ -18,6 +18,8 @@ pub mod policy; pub mod softlock; pub mod totp; +use self::totp::TOTP_DEFAULT_STEP; + use crate::credential::policy::CryptoPolicy; use crate::credential::softlock::CredSoftLockPolicy; use crate::credential::totp::Totp; @@ -498,7 +500,7 @@ pub enum CredentialType { GeneratedPassword(Password), PasswordMfa( Password, - Option, + Map, Map, Option, ), @@ -513,16 +515,18 @@ impl From<&Credential> for CredentialDetail { CredentialType::Password(_) => CredentialDetailType::Password, CredentialType::GeneratedPassword(_) => CredentialDetailType::GeneratedPassword, CredentialType::Webauthn(wan) => { - let mut labels: Vec<_> = wan.keys().cloned().collect(); - labels.sort_unstable(); + let labels: Vec<_> = wan.keys().cloned().collect(); CredentialDetailType::Passkey(labels) } CredentialType::PasswordMfa(_, totp, wan, backup_code) => { - let mut labels: Vec<_> = wan.keys().cloned().collect(); - labels.sort_unstable(); + // Don't sort - we need these in order to match to what the user + // sees so they can remove by index. + let wan_labels: Vec<_> = wan.keys().cloned().collect(); + let totp_labels: Vec<_> = totp.keys().cloned().collect(); + CredentialDetailType::PasswordMfa( - totp.is_some(), - labels, + totp_labels, + wan_labels, backup_code.as_ref().map(|c| c.code_set.len()).unwrap_or(0), ) } @@ -588,8 +592,12 @@ impl TryFrom for Credential { let v_password = Password::try_from(db_password)?; let v_totp = match totp { - Some(dbt) => Some(Totp::try_from(dbt)?), - None => None, + Some(dbt) => { + let l = "totp".to_string(); + let t = Totp::try_from(dbt)?; + Map::from([(l, t)]) + } + None => Map::default(), }; let v_webauthn = match maybe_db_webauthn { @@ -679,10 +687,13 @@ impl TryFrom for Credential { } => { let v_password = Password::try_from(db_password)?; - let v_totp = if let Some(db_totp) = maybe_db_totp { - Some(Totp::try_from(db_totp)?) - } else { - None + let v_totp = match maybe_db_totp { + Some(dbt) => { + let l = "totp".to_string(); + let t = Totp::try_from(dbt)?; + Map::from([(l, t)]) + } + None => Map::default(), }; let v_backup_code = match backup_code { @@ -701,6 +712,36 @@ impl TryFrom for Credential { Err(()) } } + DbCred::V3PasswordMfa { + password: db_password, + totp: db_totp, + backup_code, + webauthn: db_webauthn, + uuid, + } => { + let v_password = Password::try_from(db_password)?; + + let v_totp = db_totp + .into_iter() + .map(|(l, dbt)| Totp::try_from(dbt).map(|t| (l, t))) + .collect::, _>>()?; + + let v_backup_code = match backup_code { + Some(dbb) => Some(BackupCodes::try_from(dbb)?), + None => None, + }; + + let v_webauthn = db_webauthn.into_iter().collect(); + + let type_ = + CredentialType::PasswordMfa(v_password, v_totp, v_webauthn, v_backup_code); + + if type_.is_valid() { + Ok(Credential { type_, uuid }) + } else { + Err(()) + } + } credential => { error!("Database content may be corrupt - invalid credential state"); debug!(%credential); @@ -760,7 +801,7 @@ impl Credential { CredentialType::Password(pw) | CredentialType::GeneratedPassword(pw) => { let mut wan = Map::new(); wan.insert(label, cred); - CredentialType::PasswordMfa(pw.clone(), None, wan, None) + CredentialType::PasswordMfa(pw.clone(), Map::default(), wan, None) } CredentialType::PasswordMfa(pw, totp, map, backup_code) => { let mut nmap = map.clone(); @@ -802,7 +843,7 @@ impl Credential { ))); } if nmap.is_empty() { - if totp.is_some() { + if !totp.is_empty() { CredentialType::PasswordMfa( pw.clone(), totp.clone(), @@ -915,9 +956,12 @@ impl Credential { password: pw.to_dbpasswordv1(), uuid, }, - CredentialType::PasswordMfa(pw, totp, map, backup_code) => DbCred::V2PasswordMfa { + CredentialType::PasswordMfa(pw, totp, map, backup_code) => DbCred::V3PasswordMfa { password: pw.to_dbpasswordv1(), - totp: totp.as_ref().map(|t| t.to_dbtotpv1()), + totp: totp + .iter() + .map(|(l, t)| (l.clone(), t.to_dbtotpv1())) + .collect(), backup_code: backup_code.as_ref().map(|b| b.to_dbbackupcodev1()), webauthn: map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), uuid, @@ -947,17 +991,23 @@ impl Credential { } // We don't make totp accessible from outside the crate for now. - pub(crate) fn update_totp(&self, totp: Totp) -> Self { + pub(crate) fn append_totp(&self, label: String, totp: Totp) -> Self { let type_ = match &self.type_ { CredentialType::Password(pw) | CredentialType::GeneratedPassword(pw) => { - CredentialType::PasswordMfa(pw.clone(), Some(totp), Map::new(), None) + CredentialType::PasswordMfa( + pw.clone(), + Map::from([(label, totp)]), + Map::new(), + None, + ) + } + CredentialType::PasswordMfa(pw, totps, wan, backup_code) => { + let mut totps = totps.clone(); + let replaced = totps.insert(label, totp).is_none(); + debug_assert!(replaced); + + CredentialType::PasswordMfa(pw.clone(), totps, wan.clone(), backup_code.clone()) } - CredentialType::PasswordMfa(pw, _, wan, backup_code) => CredentialType::PasswordMfa( - pw.clone(), - Some(totp), - wan.clone(), - backup_code.clone(), - ), CredentialType::Webauthn(wan) => { debug_assert!(false); CredentialType::Webauthn(wan.clone()) @@ -969,14 +1019,18 @@ impl Credential { } } - pub(crate) fn remove_totp(&self) -> Self { + pub(crate) fn remove_totp(&self, label: &str) -> Self { let type_ = match &self.type_ { - CredentialType::PasswordMfa(pw, Some(_), wan, backup_code) => { - if wan.is_empty() { + CredentialType::PasswordMfa(pw, totp, wan, backup_code) => { + let mut totp = totp.clone(); + let removed = totp.remove(label).is_some(); + debug_assert!(removed); + + if wan.is_empty() && totp.is_empty() { // Note: No need to keep backup code if it is no longer MFA CredentialType::Password(pw.clone()) } else { - CredentialType::PasswordMfa(pw.clone(), None, wan.clone(), backup_code.clone()) + CredentialType::PasswordMfa(pw.clone(), totp, wan.clone(), backup_code.clone()) } } _ => self.type_.clone(), @@ -1008,8 +1062,14 @@ impl Credential { } CredentialType::PasswordMfa(_pw, totp, wan, _) => { // For backup code, use totp/wan policy (whatever is available) - if let Some(r_totp) = totp { - CredSoftLockPolicy::Totp(r_totp.step) + if !totp.is_empty() { + // What's the min step? + let min_step = totp + .iter() + .map(|(_, t)| t.step) + .min() + .unwrap_or(TOTP_DEFAULT_STEP); + CredSoftLockPolicy::Totp(min_step) } else if !wan.is_empty() { CredSoftLockPolicy::Webauthn } else { @@ -1101,7 +1161,7 @@ impl CredentialType { match self { CredentialType::Password(_) | CredentialType::GeneratedPassword(_) => true, CredentialType::PasswordMfa(_, m_totp, webauthn, _) => { - m_totp.is_some() || !webauthn.is_empty() // ignore backup code (it should only be a complement for totp/webauth) + !m_totp.is_empty() || !webauthn.is_empty() // ignore backup code (it should only be a complement for totp/webauth) } CredentialType::Webauthn(webauthn) => !webauthn.is_empty(), } diff --git a/kanidmd/lib/src/entry.rs b/kanidmd/lib/src/entry.rs index 3a2e0c448..d8c568b3f 100644 --- a/kanidmd/lib/src/entry.rs +++ b/kanidmd/lib/src/entry.rs @@ -1774,20 +1774,14 @@ impl Entry { .chain( l_attrs .iter() - .map(|k| ( - k.as_str(), - ldap_vattr_map(k.as_str()).unwrap_or(k.as_str()) - )) + .map(|k| (k.as_str(), ldap_vattr_map(k.as_str()).unwrap_or(k.as_str()))), ) .collect() } else { // Just get the requested ones. l_attrs .iter() - .map(|k| ( - k.as_str(), - ldap_vattr_map(k.as_str()).unwrap_or(k.as_str()) - )) + .map(|k| (k.as_str(), ldap_vattr_map(k.as_str()).unwrap_or(k.as_str()))) .collect() }; diff --git a/kanidmd/lib/src/idm/authsession.rs b/kanidmd/lib/src/idm/authsession.rs index fab1b4a2c..22f18c3be 100644 --- a/kanidmd/lib/src/idm/authsession.rs +++ b/kanidmd/lib/src/idm/authsession.rs @@ -65,7 +65,7 @@ enum CredVerifyState { struct CredMfa { pw: Password, pw_state: CredVerifyState, - totp: Option, + totp: BTreeMap, wan: Option<(RequestChallengeResponse, SecurityKeyAuthentication)>, backup_code: Option, mfa_state: CredVerifyState, @@ -120,14 +120,17 @@ impl TryFrom<(&Credential, &Webauthn)> for CredHandler { let cmfa = Box::new(CredMfa { pw: pw.clone(), pw_state: CredVerifyState::Init, - totp: maybe_totp.clone(), + totp: maybe_totp + .iter() + .map(|(l, t)| (l.clone(), t.clone())) + .collect(), wan, backup_code: maybe_backup_code.clone(), mfa_state: CredVerifyState::Init, }); // Paranoia. Should NEVER occur. - if cmfa.totp.is_none() && cmfa.wan.is_none() { + if cmfa.totp.is_empty() && cmfa.wan.is_none() { security_critical!("Unable to create CredHandler::PasswordMfa - totp and webauthn are both not present. Credentials MAY be corrupt!"); return Err(()); } @@ -285,7 +288,7 @@ impl CredHandler { // MFA first match ( cred, - pw_mfa.totp.as_ref(), + !pw_mfa.totp.is_empty(), pw_mfa.wan.as_ref(), pw_mfa.backup_code.as_ref(), ) { @@ -321,11 +324,19 @@ impl CredHandler { } } } - (AuthCredential::Totp(totp_chal), Some(totp), _, _) => { - if totp.verify(*totp_chal, ts) { + (AuthCredential::Totp(totp_chal), true, _, _) => { + // So long as one totp matches, success. Log which token was used. + // We don't need to worry about the empty case since none will match and we + // will get the failure. + if let Some(label) = pw_mfa + .totp + .iter() + .find(|(_, t)| t.verify(*totp_chal, ts)) + .map(|(l, _)| l) + { pw_mfa.mfa_state = CredVerifyState::Success; security_info!( - "Handler::PasswordMfa -> Result::Continue - TOTP OK, password -" + "Handler::PasswordMfa -> Result::Continue - TOTP ({}) OK, password -", label ); CredState::Continue(vec![AuthAllowed::Password]) } else { @@ -501,7 +512,11 @@ impl CredHandler { .backup_code .iter() .map(|_| AuthAllowed::BackupCode) - .chain(pw_mfa.totp.iter().map(|_| AuthAllowed::Totp)) + // This looks weird but the idea is that if at least *one* + // totp exists, then we only offer TOTP once. If none are + // there we offer it none. + .chain(pw_mfa.totp.iter().next().map(|_| AuthAllowed::Totp)) + // This iter is over an option so it's there or not. .chain( pw_mfa .wan @@ -1180,7 +1195,7 @@ mod tests { let p = CryptoPolicy::minimum(); let cred = Credential::new_password_only(&p, pw_good) .unwrap() - .update_totp(totp); + .append_totp("totp".to_string(), totp); // add totp also account.primary = Some(cred); @@ -1335,7 +1350,7 @@ mod tests { let p = CryptoPolicy::minimum(); let cred = Credential::new_password_only(&p, pw_badlist) .unwrap() - .update_totp(totp); + .append_totp("totp".to_string(), totp); // add totp also account.primary = Some(cred); @@ -1820,7 +1835,7 @@ mod tests { .unwrap() .append_securitykey("soft".to_string(), wan_cred) .unwrap() - .update_totp(totp); + .append_totp("totp".to_string(), totp); account.primary = Some(cred); @@ -2073,7 +2088,7 @@ mod tests { let p = CryptoPolicy::minimum(); let cred = Credential::new_password_only(&p, pw_good) .unwrap() - .update_totp(totp) + .append_totp("totp".to_string(), totp) .update_backup_code(backup_codes) .unwrap(); @@ -2234,4 +2249,115 @@ mod tests { drop(async_tx); assert!(async_rx.blocking_recv().is_none()); } + + #[test] + fn test_idm_authsession_multiple_totp_password_mech() { + // Slightly different to the other TOTP test, this + // checks handling when multiple TOTP's are registered. + let _ = sketching::test_init(); + let webauthn = create_webauthn(); + let jws_signer = create_jwt_signer(); + // create the ent + let mut account = entry_to_account!(E_ADMIN_V1); + + // Setup a fake time stamp for consistency. + let ts = Duration::from_secs(12345); + + // manually load in a cred + let totp_a = Totp::generate_secure(TOTP_DEFAULT_STEP); + let totp_b = Totp::generate_secure(TOTP_DEFAULT_STEP); + + let totp_good_a = totp_a + .do_totp_duration_from_epoch(&ts) + .expect("failed to perform totp."); + + let totp_good_b = totp_b + .do_totp_duration_from_epoch(&ts) + .expect("failed to perform totp."); + + assert!(totp_good_a != totp_good_b); + + let pw_good = "test_password"; + + let p = CryptoPolicy::minimum(); + let cred = Credential::new_password_only(&p, pw_good) + .unwrap() + .append_totp("totp_a".to_string(), totp_a) + .append_totp("totp_b".to_string(), totp_b); + // add totp also + account.primary = Some(cred); + + let (async_tx, mut async_rx) = unbounded(); + + // Test totp_a + { + let (mut session, _, pw_badlist_cache) = + start_password_mfa_session!(account, &webauthn); + + match session.validate_creds( + &AuthCredential::Totp(totp_good_a), + &ts, + &async_tx, + &webauthn, + Some(&pw_badlist_cache), + &jws_signer, + ) { + Ok(AuthState::Continue(cont)) => assert!(cont == vec![AuthAllowed::Password]), + _ => panic!(), + }; + match session.validate_creds( + &AuthCredential::Password(pw_good.to_string()), + &ts, + &async_tx, + &webauthn, + Some(&pw_badlist_cache), + &jws_signer, + ) { + Ok(AuthState::Success(_, AuthIssueSession::Token)) => {} + _ => panic!(), + }; + + match async_rx.blocking_recv() { + Some(DelayedAction::AuthSessionRecord(_)) => {} + _ => assert!(false), + } + } + + // Test totp_b + { + let (mut session, _, pw_badlist_cache) = + start_password_mfa_session!(account, &webauthn); + + match session.validate_creds( + &AuthCredential::Totp(totp_good_b), + &ts, + &async_tx, + &webauthn, + Some(&pw_badlist_cache), + &jws_signer, + ) { + Ok(AuthState::Continue(cont)) => assert!(cont == vec![AuthAllowed::Password]), + _ => panic!(), + }; + match session.validate_creds( + &AuthCredential::Password(pw_good.to_string()), + &ts, + &async_tx, + &webauthn, + Some(&pw_badlist_cache), + &jws_signer, + ) { + Ok(AuthState::Success(_, AuthIssueSession::Token)) => {} + _ => panic!(), + }; + + match async_rx.blocking_recv() { + Some(DelayedAction::AuthSessionRecord(_)) => {} + _ => assert!(false), + } + } + + drop(async_tx); + assert!(async_rx.blocking_recv().is_none()); + } } diff --git a/kanidmd/lib/src/idm/credupdatesession.rs b/kanidmd/lib/src/idm/credupdatesession.rs index e25407529..511963514 100644 --- a/kanidmd/lib/src/idm/credupdatesession.rs +++ b/kanidmd/lib/src/idm/credupdatesession.rs @@ -57,7 +57,7 @@ enum MfaRegState { None, TotpInit(Totp), TotpTryAgain(Totp), - TotpInvalidSha1(Totp, Totp), + TotpInvalidSha1(Totp, Totp, String), Passkey(Box, PasskeyRegistration), } @@ -67,7 +67,7 @@ impl fmt::Debug for MfaRegState { MfaRegState::None => "MfaRegState::None", MfaRegState::TotpInit(_) => "MfaRegState::TotpInit", MfaRegState::TotpTryAgain(_) => "MfaRegState::TotpTryAgain", - MfaRegState::TotpInvalidSha1(_, _) => "MfaRegState::TotpInvalidSha1", + MfaRegState::TotpInvalidSha1(_, _, _) => "MfaRegState::TotpInvalidSha1", MfaRegState::Passkey(_, _) => "MfaRegState::Passkey", }; write!(f, "{}", t) @@ -116,6 +116,37 @@ impl fmt::Debug for CredentialUpdateSession { } } +impl CredentialUpdateSession { + // In future this should be a Vec of the issues with the current session so that UI's can highlight + // properly how to proceed. + fn can_commit(&self) -> bool { + // Should be it's own PR and use account policy + + /* + // We'll check policy here in future. + let is_primary_valid = match self.primary.as_ref() { + Some(Credential { + uuid: _, + type_: CredentialType::Password(_), + }) => { + // We refuse password-only auth now. + info!("Password only authentication."); + false + } + // So far valid. + _ => true, + }; + + info!("can_commit -> {}", is_primary_valid); + + // For logic later. + is_primary_valid + */ + + true + } +} + enum MfaRegStateStatus { // Nothing in progress. None, @@ -183,8 +214,7 @@ impl From<&CredentialUpdateSession> for CredentialUpdateSessionStatus { CredentialUpdateSessionStatus { spn: session.account.spn.clone(), displayname: session.account.displayname.clone(), - - can_commit: true, + can_commit: session.can_commit(), primary: session.primary.as_ref().map(|c| c.into()), passkeys: session .passkeys @@ -200,7 +230,7 @@ impl From<&CredentialUpdateSession> for CredentialUpdateSessionStatus { token.to_proto(session.account.name.as_str(), session.issuer.as_str()), ), MfaRegState::TotpTryAgain(_) => MfaRegStateStatus::TotpTryAgain, - MfaRegState::TotpInvalidSha1(_, _) => MfaRegStateStatus::TotpInvalidSha1, + MfaRegState::TotpInvalidSha1(_, _, _) => MfaRegStateStatus::TotpInvalidSha1, MfaRegState::Passkey(r, _) => MfaRegStateStatus::Passkey(r.as_ref().clone()), }, } @@ -728,6 +758,12 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { let (mut modlist, session, session_token) = self.credential_update_commit_common(cust, ct)?; + // Can we actually proceed? + if !session.can_commit() { + admin_error!("Session is unable to commit due to a constraint violation."); + return Err(OperationError::InvalidState); + } + // Setup mods for the various bits. We always assert an *exact* state. // IF an intent was used on this session, AND that intent is not in our @@ -1150,6 +1186,7 @@ impl<'a> IdmServerCredUpdateTransaction<'a> { cust: &CredentialUpdateSessionToken, ct: Duration, totp_chal: u32, + label: &str, ) -> Result { let session_handle = self.get_current_session(cust, ct)?; let mut session = session_handle.try_lock().map_err(|_| { @@ -1162,13 +1199,13 @@ impl<'a> IdmServerCredUpdateTransaction<'a> { match &session.mfaregstate { MfaRegState::TotpInit(totp_token) | MfaRegState::TotpTryAgain(totp_token) - | MfaRegState::TotpInvalidSha1(totp_token, _) => { + | MfaRegState::TotpInvalidSha1(totp_token, _, _) => { if totp_token.verify(totp_chal, &ct) { // It was valid. Update the credential. let ncred = session .primary .as_ref() - .map(|cred| cred.update_totp(totp_token.clone())) + .map(|cred| cred.append_totp(label.to_string(), totp_token.clone())) .ok_or_else(|| { admin_error!("A TOTP was added, but no primary credential stub exists"); OperationError::InvalidState @@ -1188,8 +1225,11 @@ impl<'a> IdmServerCredUpdateTransaction<'a> { if token_sha1.verify(totp_chal, &ct) { // Greeeaaaaaatttt. It's a broken app. Let's check the user // knows this is broken, before we proceed. - session.mfaregstate = - MfaRegState::TotpInvalidSha1(totp_token.clone(), token_sha1); + session.mfaregstate = MfaRegState::TotpInvalidSha1( + totp_token.clone(), + token_sha1, + label.to_string(), + ); Ok(session.deref().into()) } else { // Let them check again, it's a typo. @@ -1216,12 +1256,12 @@ impl<'a> IdmServerCredUpdateTransaction<'a> { // Are we in a totp reg state? match &session.mfaregstate { - MfaRegState::TotpInvalidSha1(_, token_sha1) => { + MfaRegState::TotpInvalidSha1(_, token_sha1, label) => { // They have accepted it as sha1 let ncred = session .primary .as_ref() - .map(|cred| cred.update_totp(token_sha1.clone())) + .map(|cred| cred.append_totp(label.to_string(), token_sha1.clone())) .ok_or_else(|| { admin_error!("A TOTP was added, but no primary credential stub exists"); OperationError::InvalidState @@ -1243,6 +1283,7 @@ impl<'a> IdmServerCredUpdateTransaction<'a> { &self, cust: &CredentialUpdateSessionToken, ct: Duration, + label: &str, ) -> Result { let session_handle = self.get_current_session(cust, ct)?; let mut session = session_handle.try_lock().map_err(|_| { @@ -1259,7 +1300,7 @@ impl<'a> IdmServerCredUpdateTransaction<'a> { let ncred = session .primary .as_ref() - .map(|cred| cred.remove_totp()) + .map(|cred| cred.remove_totp(label)) .ok_or_else(|| { admin_error!("Try to remove TOTP, but no primary credential stub exists"); OperationError::InvalidState @@ -2086,7 +2127,7 @@ mod tests { // Intentionally get it wrong. let c_status = cutxn - .credential_primary_check_totp(&cust, ct, chal + 1) + .credential_primary_check_totp(&cust, ct, chal + 1, "totp") .expect("Failed to update the primary cred password"); assert!(matches!( @@ -2095,14 +2136,14 @@ mod tests { )); let c_status = cutxn - .credential_primary_check_totp(&cust, ct, chal) + .credential_primary_check_totp(&cust, ct, chal, "totp") .expect("Failed to update the primary cred password"); assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None)); - assert!(matches!( - c_status.primary.as_ref().map(|c| &c.type_), - Some(CredentialDetailType::PasswordMfa(true, _, 0)) - )); + assert!(match c_status.primary.as_ref().map(|c| &c.type_) { + Some(CredentialDetailType::PasswordMfa(totp, _, 0)) => !totp.is_empty(), + _ => false, + }); // Should be okay now! @@ -2122,7 +2163,7 @@ mod tests { let cutxn = idms.cred_update_transaction(); let c_status = cutxn - .credential_primary_remove_totp(&cust, ct) + .credential_primary_remove_totp(&cust, ct, "totp") .expect("Failed to update the primary cred password"); assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None)); @@ -2182,7 +2223,7 @@ mod tests { // Should getn the warn that it's sha1 let c_status = cutxn - .credential_primary_check_totp(&cust, ct, chal) + .credential_primary_check_totp(&cust, ct, chal, "totp") .expect("Failed to update the primary cred password"); assert!(matches!( @@ -2196,10 +2237,10 @@ mod tests { .expect("Failed to update the primary cred password"); assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None)); - assert!(matches!( - c_status.primary.as_ref().map(|c| &c.type_), - Some(CredentialDetailType::PasswordMfa(true, _, 0)) - )); + assert!(match c_status.primary.as_ref().map(|c| &c.type_) { + Some(CredentialDetailType::PasswordMfa(totp, _, 0)) => !totp.is_empty(), + _ => false, + }); // Should be okay now! @@ -2243,7 +2284,6 @@ mod tests { let totp_token: Totp = match c_status.mfaregstate { MfaRegStateStatus::TotpCheck(secret) => Some(secret.into()), - _ => None, } .expect("Unable to retrieve totp token, invalid state."); @@ -2254,14 +2294,14 @@ mod tests { .expect("Failed to perform totp step"); let c_status = cutxn - .credential_primary_check_totp(&cust, ct, chal) - .expect("Failed to update the primary cred password"); + .credential_primary_check_totp(&cust, ct, chal, "totp") + .expect("Failed to update the primary cred totp"); assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None)); - assert!(matches!( - c_status.primary.as_ref().map(|c| &c.type_), - Some(CredentialDetailType::PasswordMfa(true, _, 0)) - )); + assert!(match c_status.primary.as_ref().map(|c| &c.type_) { + Some(CredentialDetailType::PasswordMfa(totp, _, 0)) => !totp.is_empty(), + _ => false, + }); // Now good to go, we need to now add our backup codes. // What's the right way to get these back? @@ -2277,10 +2317,10 @@ mod tests { // Should error because the number is not 0 debug!("{:?}", c_status.primary.as_ref().map(|c| &c.type_)); - assert!(matches!( - c_status.primary.as_ref().map(|c| &c.type_), - Some(CredentialDetailType::PasswordMfa(true, _, 8)) - )); + assert!(match c_status.primary.as_ref().map(|c| &c.type_) { + Some(CredentialDetailType::PasswordMfa(totp, _, 8)) => !totp.is_empty(), + _ => false, + }); // Should be okay now! drop(cutxn); @@ -2308,10 +2348,10 @@ mod tests { .credential_update_status(&cust, ct) .expect("Failed to get the current session status."); - assert!(matches!( - c_status.primary.as_ref().map(|c| &c.type_), - Some(CredentialDetailType::PasswordMfa(true, _, 7)) - )); + assert!(match c_status.primary.as_ref().map(|c| &c.type_) { + Some(CredentialDetailType::PasswordMfa(totp, _, 7)) => !totp.is_empty(), + _ => false, + }); // If we remove codes, it leaves totp. let c_status = cutxn @@ -2319,10 +2359,10 @@ mod tests { .expect("Failed to update the primary cred password"); assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None)); - assert!(matches!( - c_status.primary.as_ref().map(|c| &c.type_), - Some(CredentialDetailType::PasswordMfa(true, _, 0)) - )); + assert!(match c_status.primary.as_ref().map(|c| &c.type_) { + Some(CredentialDetailType::PasswordMfa(totp, _, 0)) => !totp.is_empty(), + _ => false, + }); // Re-add the codes. let c_status = cutxn @@ -2333,14 +2373,14 @@ mod tests { c_status.mfaregstate, MfaRegStateStatus::BackupCodes(_) )); - assert!(matches!( - c_status.primary.as_ref().map(|c| &c.type_), - Some(CredentialDetailType::PasswordMfa(true, _, 8)) - )); + assert!(match c_status.primary.as_ref().map(|c| &c.type_) { + Some(CredentialDetailType::PasswordMfa(totp, _, 8)) => !totp.is_empty(), + _ => false, + }); // If we remove totp, it removes codes. let c_status = cutxn - .credential_primary_remove_totp(&cust, ct) + .credential_primary_remove_totp(&cust, ct, "totp") .expect("Failed to update the primary cred password"); assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None)); diff --git a/kanidmd/lib/src/idm/ldap.rs b/kanidmd/lib/src/idm/ldap.rs index f67d4218b..f1c40851f 100644 --- a/kanidmd/lib/src/idm/ldap.rs +++ b/kanidmd/lib/src/idm/ldap.rs @@ -265,10 +265,7 @@ impl LdapServer { if a == "entrydn" || a == "dn" { None } else { - Some(AttrString::from( - ldap_vattr_map(a) - .unwrap_or(a) - )) + Some(AttrString::from(ldap_vattr_map(a).unwrap_or(a))) } }) .collect(); @@ -1149,7 +1146,7 @@ mod tests { // Already being returned "name".to_string(), // This is a virtual attribute - "entryuuid".to_string() + "entryuuid".to_string(), ], }; let r1 = task::block_on(ldaps.do_search(idms, &sr, &anon_t)).unwrap(); @@ -1169,8 +1166,6 @@ mod tests { } _ => assert!(false), }; - - } ) } diff --git a/kanidmd/lib/src/macros.rs b/kanidmd/lib/src/macros.rs index f42d05e3f..a3e1805b4 100644 --- a/kanidmd/lib/src/macros.rs +++ b/kanidmd/lib/src/macros.rs @@ -72,6 +72,29 @@ macro_rules! entry_str_to_account { }}; } +#[cfg(test)] +macro_rules! entry_to_account { + ($entry:expr) => {{ + use std::iter::once; + + use crate::entry::{Entry, EntryInvalid, EntryNew}; + use crate::idm::account::Account; + use crate::value::Value; + + let mut e: Entry = unsafe { $entry.clone().into_invalid_new() }; + // Add spn, because normally this is generated but in tests we can't. + let spn = e + .get_ava_single_iname("name") + .map(|s| Value::new_spn_str(s, "example.com")) + .expect("Failed to munge spn from name!"); + e.set_ava("spn", once(spn)); + + let e = unsafe { e.into_sealed_committed() }; + + Account::try_from_entry_no_groups(&e).expect("Account conversion failure") + }}; +} + #[cfg(test)] macro_rules! run_idm_test_inner { ($test_fn:expr) => {{ diff --git a/kanidmd/lib/src/plugins/domain.rs b/kanidmd/lib/src/plugins/domain.rs index 281ddeddc..948b15d65 100644 --- a/kanidmd/lib/src/plugins/domain.rs +++ b/kanidmd/lib/src/plugins/domain.rs @@ -69,6 +69,14 @@ impl Domain { e.set_ava("domain_name", once(n)); trace!("plugin_domain: Applying domain_name transform"); } + + // Setup the minimum functional level if one is not set already. + if !e.attribute_pres("version") { + let n = Value::Uint32(DOMAIN_MIN_LEVEL); + e.set_ava("version", once(n)); + trace!("plugin_domain: Applying domain version transform"); + } + // create the domain_display_name if it's missing if !e.attribute_pres("domain_display_name") { let domain_display_name = Value::new_utf8(format!("Kanidm {}", qs.get_domain_name())); diff --git a/kanidmd/lib/src/plugins/password_import.rs b/kanidmd/lib/src/plugins/password_import.rs index 5df8509f7..03b19411a 100644 --- a/kanidmd/lib/src/plugins/password_import.rs +++ b/kanidmd/lib/src/plugins/password_import.rs @@ -261,7 +261,7 @@ mod tests { let p = CryptoPolicy::minimum(); let c = Credential::new_password_only(&p, "password") .unwrap() - .update_totp(totp); + .append_totp("totp".to_string(), totp); ea.add_ava("primary_credential", Value::new_credential("primary", c)); let preload = vec![ea]; @@ -285,7 +285,7 @@ mod tests { .expect("failed to get primary cred."); match &c.type_ { CredentialType::PasswordMfa(_pw, totp, webauthn, backup_code) => { - assert!(totp.is_some()); + assert!(!totp.is_empty()); assert!(webauthn.is_empty()); assert!(backup_code.is_none()); } diff --git a/kanidmd/lib/src/server/migrations.rs b/kanidmd/lib/src/server/migrations.rs index 4e3281a07..0e8790c66 100644 --- a/kanidmd/lib/src/server/migrations.rs +++ b/kanidmd/lib/src/server/migrations.rs @@ -78,32 +78,20 @@ impl QueryServer { }?; admin_debug!(?system_info_version); - if system_info_version < 3 { - migrate_txn.migrate_2_to_3()?; - } + if system_info_version > 0 { + if system_info_version <= 6 { + error!("Your instance of Kanidm is version 1.1.0-alpha.9 or lower, and you are trying to perform a skip upgrade. This will not work."); + error!("You need to upgrade one version at a time to ensure upgrade migrations are performed in the correct order."); + return Err(OperationError::InvalidState); + } - if system_info_version < 4 { - migrate_txn.migrate_3_to_4()?; - } + if system_info_version < 9 { + migrate_txn.migrate_8_to_9()?; + } - if system_info_version < 5 { - migrate_txn.migrate_4_to_5()?; - } - - if system_info_version < 6 { - migrate_txn.migrate_5_to_6()?; - } - - if system_info_version < 7 { - migrate_txn.migrate_6_to_7()?; - } - - if system_info_version < 8 { - migrate_txn.migrate_7_to_8()?; - } - - if system_info_version < 9 { - migrate_txn.migrate_8_to_9()?; + if system_info_version < 10 { + migrate_txn.migrate_9_to_10()?; + } } migrate_txn.commit()?; @@ -114,7 +102,7 @@ impl QueryServer { ts_write_3.set_phase(ServerPhase::Running); ts_write_3.commit() })?; - // TODO: work out if we've actually done any migrations before printing this + admin_debug!("Database version check and migrations success! ☀️ "); Ok(()) } @@ -183,146 +171,6 @@ impl<'a> QueryServerWriteTransaction<'a> { } } - /// Migrate 2 to 3 changes the name, domain_name types from iutf8 to iname. - #[instrument(level = "debug", skip_all)] - pub fn migrate_2_to_3(&mut self) -> Result<(), OperationError> { - admin_warn!("starting 2 to 3 migration. THIS MAY TAKE A LONG TIME!"); - // Get all entries where pres name or domain_name. INCLUDE TS + RECYCLE. - - let filt = filter_all!(f_or!([f_pres("name"), f_pres("domain_name"),])); - - let pre_candidates = self.internal_search(filt).map_err(|e| { - admin_error!(err = ?e, "migrate_2_to_3 internal search failure"); - e - })?; - - // If there is nothing, we don't need to do anything. - if pre_candidates.is_empty() { - admin_info!("migrate_2_to_3 no entries to migrate, complete"); - return Ok(()); - } - - // Change the value type. - let mut candidates: Vec> = pre_candidates - .iter() - .map(|er| er.as_ref().clone().invalidate(self.cid.clone())) - .collect(); - - candidates.iter_mut().try_for_each(|er| { - let nvs = if let Some(vs) = er.get_ava_set("name") { - vs.migrate_iutf8_iname()? - } else { - None - }; - if let Some(nvs) = nvs { - er.set_ava_set("name", nvs) - } - - let nvs = if let Some(vs) = er.get_ava_set("domain_name") { - vs.migrate_iutf8_iname()? - } else { - None - }; - if let Some(nvs) = nvs { - er.set_ava_set("domain_name", nvs) - } - - Ok(()) - })?; - - // Schema check all. - let res: Result>, SchemaError> = candidates - .into_iter() - .map(|e| e.validate(&self.schema).map(|e| e.seal(&self.schema))) - .collect(); - - let norm_cand: Vec> = match res { - Ok(v) => v, - Err(e) => { - admin_error!("migrate_2_to_3 schema error -> {:?}", e); - return Err(OperationError::SchemaViolation(e)); - } - }; - - // Write them back. - self.be_txn - .modify(&self.cid, &pre_candidates, &norm_cand) - .map_err(|e| { - admin_error!("migrate_2_to_3 modification failure -> {:?}", e); - e - }) - // Complete - } - - /// Migrate 3 to 4 - this triggers a regen of the domains security token - /// as we previously did not have it in the entry. - #[instrument(level = "debug", skip_all)] - pub fn migrate_3_to_4(&mut self) -> Result<(), OperationError> { - admin_warn!("starting 3 to 4 migration."); - let filter = filter!(f_eq("uuid", (*PVUUID_DOMAIN_INFO).clone())); - let modlist = ModifyList::new_purge("domain_token_key"); - self.internal_modify(&filter, &modlist) - // Complete - } - - /// Migrate 4 to 5 - this triggers a regen of all oauth2 RS es256 der keys - /// as we previously did not generate them on entry creation. - #[instrument(level = "debug", skip_all)] - pub fn migrate_4_to_5(&mut self) -> Result<(), OperationError> { - admin_warn!("starting 4 to 5 migration."); - let filter = filter!(f_and!([ - f_eq("class", (*PVCLASS_OAUTH2_RS).clone()), - f_andnot(f_pres("es256_private_key_der")), - ])); - let modlist = ModifyList::new_purge("es256_private_key_der"); - self.internal_modify(&filter, &modlist) - // Complete - } - - /// Migrate 5 to 6 - This updates the domain info item to reset the token - /// keys based on the new encryption types. - #[instrument(level = "debug", skip_all)] - pub fn migrate_5_to_6(&mut self) -> Result<(), OperationError> { - admin_warn!("starting 5 to 6 migration."); - let filter = filter!(f_eq("uuid", (*PVUUID_DOMAIN_INFO).clone())); - let mut modlist = ModifyList::new_purge("domain_token_key"); - // We need to also push the version here so that we pass schema. - modlist.push_mod(Modify::Present( - AttrString::from("version"), - Value::Uint32(0), - )); - self.internal_modify(&filter, &modlist) - // Complete - } - - /// Migrate 6 to 7 - /// - /// Modify accounts that are not persons, to be service accounts so that the extension - /// rules remain valid. - #[instrument(level = "debug", skip_all)] - pub fn migrate_6_to_7(&mut self) -> Result<(), OperationError> { - admin_warn!("starting 6 to 7 migration."); - let filter = filter!(f_and!([ - f_eq("class", (*PVCLASS_ACCOUNT).clone()), - f_andnot(f_eq("class", (*PVCLASS_PERSON).clone())), - ])); - let modlist = ModifyList::new_append("class", Value::new_class("service_account")); - self.internal_modify(&filter, &modlist) - // Complete - } - - /// Migrate 7 to 8 - /// - /// Touch all service accounts to trigger a regen of their es256 jws keys for api tokens - #[instrument(level = "debug", skip_all)] - pub fn migrate_7_to_8(&mut self) -> Result<(), OperationError> { - admin_warn!("starting 7 to 8 migration."); - let filter = filter!(f_eq("class", (*PVCLASS_SERVICE_ACCOUNT).clone())); - let modlist = ModifyList::new_append("class", Value::new_class("service_account")); - self.internal_modify(&filter, &modlist) - // Complete - } - /// Migrate 8 to 9 /// /// This migration updates properties of oauth2 relying server properties. First, it changes @@ -406,6 +254,27 @@ impl<'a> QueryServerWriteTransaction<'a> { // Complete } + /// Migrate 9 to 10 + /// + /// This forces a load and rewrite of all credentials stored on all accounts so that they are + /// updated to new on-disk formats. This will allow us to purge some older on disk formats in + /// a future version. + /// + /// An extended feature of this is the ability to store multiple TOTP's per entry. + #[instrument(level = "debug", skip_all)] + pub fn migrate_9_to_10(&mut self) -> Result<(), OperationError> { + admin_warn!("starting 9 to 10 migration."); + let filter = filter!(f_or!([ + f_pres("primary_credential"), + f_pres("unix_password"), + ])); + // This "does nothing" since everything has object anyway, but it forces the entry to be + // loaded and rewritten. + let modlist = ModifyList::new_append("class", Value::new_class("object")); + self.internal_modify(&filter, &modlist) + // Complete + } + #[instrument(level = "info", skip_all)] pub fn initialise_schema_core(&mut self) -> Result<(), OperationError> { admin_debug!("initialise_schema_core -> start ..."); @@ -698,6 +567,7 @@ mod tests { } } + /* #[qs_test_no_init] async fn test_qs_upgrade_entry_attrs(server: &QueryServer) { let mut server_txn = server.write(duration_from_epoch_now()).await; @@ -813,4 +683,5 @@ mod tests { ); assert!(server_txn.commit().is_ok()); } + */ } diff --git a/kanidmd/testkit/tests/proto_v1_test.rs b/kanidmd/testkit/tests/proto_v1_test.rs index 2bab595c9..c562fece2 100644 --- a/kanidmd/testkit/tests/proto_v1_test.rs +++ b/kanidmd/testkit/tests/proto_v1_test.rs @@ -1027,7 +1027,7 @@ async fn test_server_credential_update_session_totp_pw(rsclient: KanidmClient) { .expect("Failed to do totp?"); let _status = rsclient - .idm_account_credential_update_check_totp(&session_token, totp_chal) + .idm_account_credential_update_check_totp(&session_token, totp_chal, "totp") .await .unwrap(); @@ -1061,7 +1061,7 @@ async fn test_server_credential_update_session_totp_pw(rsclient: KanidmClient) { .unwrap(); let _status = rsclient - .idm_account_credential_update_remove_totp(&session_token) + .idm_account_credential_update_remove_totp(&session_token, "totp") .await .unwrap(); diff --git a/kanidmd_web_ui/pkg/kanidmd_web_ui.js b/kanidmd_web_ui/pkg/kanidmd_web_ui.js index ef3fa9cab..fc25f7ae0 100644 --- a/kanidmd_web_ui/pkg/kanidmd_web_ui.js +++ b/kanidmd_web_ui/pkg/kanidmd_web_ui.js @@ -233,19 +233,19 @@ function addBorrowedObject(obj) { } function __wbg_adapter_50(arg0, arg1, arg2) { try { - wasm._dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h3fce4a672296c312(arg0, arg1, addBorrowedObject(arg2)); + wasm._dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h0984b1b8ad634c42(arg0, arg1, addBorrowedObject(arg2)); } finally { heap[stack_pointer++] = undefined; } } function __wbg_adapter_53(arg0, arg1, arg2) { - wasm._dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h90c46f524e922c83(arg0, arg1, addHeapObject(arg2)); + wasm._dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h721315d2d8351be6(arg0, arg1, addHeapObject(arg2)); } function __wbg_adapter_56(arg0, arg1, arg2) { try { - wasm._dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h785bf27b1a1ece21(arg0, arg1, addBorrowedObject(arg2)); + wasm._dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h6abde9f4b8744c87(arg0, arg1, addBorrowedObject(arg2)); } finally { heap[stack_pointer++] = undefined; } @@ -557,6 +557,13 @@ function getImports() { const ret = getObject(arg0).querySelector(getStringFromWasm0(arg1, arg2)); return isLikeNone(ret) ? 0 : addHeapObject(ret); }, arguments) }; + imports.wbg.__wbg_state_4896ba54c2e3301e = function() { return handleError(function (arg0) { + const ret = getObject(arg0).state; + return addHeapObject(ret); + }, arguments) }; + imports.wbg.__wbg_pushState_38917fb88b4add30 = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4, arg5) { + getObject(arg0).pushState(getObject(arg1), getStringFromWasm0(arg2, arg3), arg4 === 0 ? undefined : getStringFromWasm0(arg4, arg5)); + }, arguments) }; imports.wbg.__wbg_parentNode_e397bbbe28be7b28 = function(arg0) { const ret = getObject(arg0).parentNode; return isLikeNone(ret) ? 0 : addHeapObject(ret); @@ -621,13 +628,6 @@ function getImports() { const ret = getObject(arg0).text(); return addHeapObject(ret); }, arguments) }; - imports.wbg.__wbg_state_4896ba54c2e3301e = function() { return handleError(function (arg0) { - const ret = getObject(arg0).state; - return addHeapObject(ret); - }, arguments) }; - imports.wbg.__wbg_pushState_38917fb88b4add30 = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4, arg5) { - getObject(arg0).pushState(getObject(arg1), getStringFromWasm0(arg2, arg3), arg4 === 0 ? undefined : getStringFromWasm0(arg4, arg5)); - }, arguments) }; imports.wbg.__wbg_href_bbb11e0e61ea410e = function() { return handleError(function (arg0, arg1) { const ret = getObject(arg1).href; const ptr0 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); @@ -1159,16 +1159,16 @@ function getImports() { const ret = wasm.memory; return addHeapObject(ret); }; - imports.wbg.__wbindgen_closure_wrapper4699 = function(arg0, arg1, arg2) { - const ret = makeMutClosure(arg0, arg1, 1064, __wbg_adapter_50); + imports.wbg.__wbindgen_closure_wrapper4757 = function(arg0, arg1, arg2) { + const ret = makeMutClosure(arg0, arg1, 1089, __wbg_adapter_50); return addHeapObject(ret); }; - imports.wbg.__wbindgen_closure_wrapper5635 = function(arg0, arg1, arg2) { - const ret = makeMutClosure(arg0, arg1, 1412, __wbg_adapter_53); + imports.wbg.__wbindgen_closure_wrapper5693 = function(arg0, arg1, arg2) { + const ret = makeMutClosure(arg0, arg1, 1437, __wbg_adapter_53); return addHeapObject(ret); }; - imports.wbg.__wbindgen_closure_wrapper5705 = function(arg0, arg1, arg2) { - const ret = makeMutClosure(arg0, arg1, 1442, __wbg_adapter_56); + imports.wbg.__wbindgen_closure_wrapper5763 = function(arg0, arg1, arg2) { + const ret = makeMutClosure(arg0, arg1, 1467, __wbg_adapter_56); return addHeapObject(ret); }; diff --git a/kanidmd_web_ui/pkg/kanidmd_web_ui_bg.wasm b/kanidmd_web_ui/pkg/kanidmd_web_ui_bg.wasm index 17299c280..32be2c839 100644 Binary files a/kanidmd_web_ui/pkg/kanidmd_web_ui_bg.wasm and b/kanidmd_web_ui/pkg/kanidmd_web_ui_bg.wasm differ diff --git a/kanidmd_web_ui/src/credential/mod.rs b/kanidmd_web_ui/src/credential/mod.rs index 72ef6587d..d9fe214e2 100644 --- a/kanidmd_web_ui/src/credential/mod.rs +++ b/kanidmd_web_ui/src/credential/mod.rs @@ -5,3 +5,4 @@ mod passkey; mod passkeyremove; mod pwmodal; mod totpmodal; +mod totpremove; diff --git a/kanidmd_web_ui/src/credential/pwmodal.rs b/kanidmd_web_ui/src/credential/pwmodal.rs index 5ea126f69..b783a3507 100644 --- a/kanidmd_web_ui/src/credential/pwmodal.rs +++ b/kanidmd_web_ui/src/credential/pwmodal.rs @@ -136,8 +136,9 @@ impl Component for PwModalApp { match msg { Msg::PasswordCheck => { // default is empty string - let pw = utils::get_value_from_element_id("password").unwrap_or_default(); - let check = utils::get_value_from_element_id("password-check").unwrap_or_default(); + let pw = utils::get_value_from_element_id("new-password").unwrap_or_default(); + let check = + utils::get_value_from_element_id("new-password-check").unwrap_or_default(); if pw == check { self.pw_check = PwCheck::Valid @@ -155,7 +156,7 @@ impl Component for PwModalApp { self.state = PwState::Waiting; // default is empty string - let pw = utils::get_value_from_element_id("password").unwrap_or_default(); + let pw = utils::get_value_from_element_id("new-password").unwrap_or_default(); let token_c = ctx.props().token.clone(); ctx.link().send_future(async { @@ -263,12 +264,13 @@ impl Component for PwModalApp { } } ) } > - + + { pw_feedback } - + , } +#[derive(PartialEq, Properties)] +pub struct TotpRemoveProps { + pub token: CUSessionToken, + pub label: String, + pub cb: Callback, +} + #[derive(PartialEq, Properties)] pub struct PasskeyRemoveModalProps { pub token: CUSessionToken, @@ -354,15 +362,68 @@ impl CredentialResetApp { html! { <>

{ "✅ Password Set" }

+

+ +

+

{ "❌ MFA Disabled" }

+

+ +

- +

+ +

+ + } + } + Some(CredentialDetail { + uuid: _, + type_: + CredentialDetailType::PasswordMfa( + // Used for what TOTP the user has. + totp_set, + // Being deprecated. + _security_key_labels, + // Need to wire in backup codes. + _backup_codes_remaining, + ), + }) => { + html! { + <> +

{ "✅ Password Set" }

+

+ +

+ +

{ "✅ MFA Enabled" }

+ + <> + { for totp_set.iter() + .map(|detail| html! { }) + } + + +

+ +

+ +

+ +

- } } @@ -392,32 +453,6 @@ impl CredentialResetApp { } } - Some(CredentialDetail { - uuid: _, - type_: - // TODO: review this and find out why we aren't using these variables - // Because I'm lazy 🙃 - CredentialDetailType::PasswordMfa( - _totp_set, - _security_key_labels, - _backup_codes_remaining, - ), - }) => { - html! { - <> -

{ "✅ Password Set" }

-

{ "✅ MFA Enabled" }

- - - - - - } - } None => { html! { + + + } + } +} + +impl TotpRemoveComp { + async fn submit_totp_update( + token: CUSessionToken, + req: CURequest, + cb: Callback, + ) -> Result { + let req_jsvalue = serde_json::to_string(&(req, token)) + .map(|s| JsValue::from(&s)) + .expect_throw("Failed to serialise pw curequest"); + + let mut opts = RequestInit::new(); + opts.method("POST"); + opts.mode(RequestMode::SameOrigin); + + opts.body(Some(&req_jsvalue)); + + let request = Request::new_with_str_and_init("/v1/credential/_update", &opts)?; + request + .headers() + .set("content-type", "application/json") + .expect_throw("failed to set header"); + + let window = utils::window(); + let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?; + let resp: Response = resp_value.dyn_into().expect_throw("Invalid response type"); + let status = resp.status(); + let headers = resp.headers(); + + let kopid = headers.get("x-kanidm-opid").ok().flatten(); + + if status == 200 { + let jsval = JsFuture::from(resp.json()?).await?; + let status: CUStatus = + serde_wasm_bindgen::from_value(jsval).expect_throw("Invalid response type"); + + cb.emit(EventBusMsg::UpdateStatus { + status: status.clone(), + }); + + Ok(Msg::Success) + } else { + let text = JsFuture::from(resp.text()?).await?; + let emsg = text.as_string().unwrap_or_default(); + Ok(Msg::Error { emsg, kopid }) + } + } +} diff --git a/kanidmd_web_ui/src/login.rs b/kanidmd_web_ui/src/login.rs index a1b656b20..de75adff9 100644 --- a/kanidmd_web_ui/src/login.rs +++ b/kanidmd_web_ui/src/login.rs @@ -268,6 +268,7 @@ impl LoginApp { name="username" oninput={ ctx.link().callback(|e: InputEvent| LoginAppMsg::Input(utils::get_value_from_input_event(e))) } type="text" + autocomplete="username" value={ inputvalue } /> @@ -333,6 +334,9 @@ impl LoginApp { LoginAppMsg::PasswordSubmit } ) } > +
+ +
@@ -377,6 +382,7 @@ impl LoginApp { name="backup_code" oninput={ ctx.link().callback(|e: InputEvent| LoginAppMsg::Input(utils::get_value_from_input_event(e))) } type="text" + autocomplete="off" value={ inputvalue } /> @@ -405,10 +411,11 @@ impl LoginApp { autofocus=true class="autofocus form-control" disabled={ state==&TotpState::Disabled } - id="totp" - name="totp" + id="otp" + name="otp" oninput={ ctx.link().callback(|e: InputEvent| LoginAppMsg::Input(utils::get_value_from_input_event(e)))} type="text" + autocomplete="off" value={ inputvalue } /> @@ -977,5 +984,10 @@ impl Component for LoginApp { fn rendered(&mut self, _ctx: &Context, _first_render: bool) { #[cfg(debug_assertions)] console::debug!("login::rendered".to_string()); + // Force autofocus on elements that need it if present. + crate::utils::autofocus("username"); + crate::utils::autofocus("password"); + crate::utils::autofocus("backup_code"); + crate::utils::autofocus("otp"); } }