1121 multiple totp (#1325)

This commit is contained in:
Firstyear 2023-01-17 14:14:11 +10:00 committed by GitHub
parent fb265391c8
commit 84fc7d0bac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 813 additions and 370 deletions

View file

@ -1468,8 +1468,9 @@ impl KanidmClient {
&self,
session_token: &CUSessionToken,
totp_chal: u32,
label: &str,
) -> Result<CUStatus, ClientError> {
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<CUStatus, ClientError> {
let scr = CURequest::TotpRemove;
let scr = CURequest::TotpRemove(label.to_string());
self.perform_simple_post_request("/v1/credential/_update", &(scr, &session_token))
.await
}

View file

@ -643,7 +643,7 @@ pub enum CredentialDetailType {
GeneratedPassword,
Passkey(Vec<String>),
/// totp, webauthn
PasswordMfa(bool, Vec<String>, usize),
PasswordMfa(Vec<String>, Vec<String>, 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",

View file

@ -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> {

View file

@ -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,

View file

@ -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<DbBackupCodeV1>,
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
),
}
}
}

View file

@ -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."]
}
}"#;

View file

@ -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;

View file

@ -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"
],

View file

@ -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<Totp>,
Map<String, Totp>,
Map<String, SecurityKey>,
Option<BackupCodes>,
),
@ -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<DbCred> 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<DbCred> 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<DbCred> 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::<Result<Map<_, _>, _>>()?;
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(),
}

View file

@ -1774,20 +1774,14 @@ impl Entry<EntryReduced, EntryCommitted> {
.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()
};

View file

@ -65,7 +65,7 @@ enum CredVerifyState {
struct CredMfa {
pw: Password,
pw_state: CredVerifyState,
totp: Option<Totp>,
totp: BTreeMap<String, Totp>,
wan: Option<(RequestChallengeResponse, SecurityKeyAuthentication)>,
backup_code: Option<BackupCodes>,
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());
}
}

View file

@ -57,7 +57,7 @@ enum MfaRegState {
None,
TotpInit(Totp),
TotpTryAgain(Totp),
TotpInvalidSha1(Totp, Totp),
TotpInvalidSha1(Totp, Totp, String),
Passkey(Box<CreationChallengeResponse>, 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<CredentialUpdateSessionStatus, OperationError> {
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<CredentialUpdateSessionStatus, OperationError> {
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));

View file

@ -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),
};
}
)
}

View file

@ -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<EntryInvalid, EntryNew> = 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) => {{

View file

@ -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()));

View file

@ -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());
}

View file

@ -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<Entry<EntryInvalid, EntryCommitted>> = 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<Vec<Entry<EntrySealed, EntryCommitted>>, SchemaError> = candidates
.into_iter()
.map(|e| e.validate(&self.schema).map(|e| e.seal(&self.schema)))
.collect();
let norm_cand: Vec<Entry<_, _>> = 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());
}
*/
}

View file

@ -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();

View file

@ -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);
};

View file

@ -5,3 +5,4 @@ mod passkey;
mod passkeyremove;
mod pwmodal;
mod totpmodal;
mod totpremove;

View file

@ -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 {
}
} ) }
>
<label for="password" class="form-label">{ "Enter New Password" }</label>
<input hidden=true type="text" autocomplete="username" />
<label for="new-password" class="form-label">{ "Enter New Password" }</label>
<input
aria-describedby="password-validation-feedback"
autocomplete="new-password"
class={ pw_class }
id="password"
id="new-password"
oninput={
ctx.link()
.callback(move |_| {
@ -281,12 +283,12 @@ impl Component for PwModalApp {
value={ pw_val }
/>
{ pw_feedback }
<label for="password-check" class="form-label">{ "Repeat Password" }</label>
<label for="new-password-check" class="form-label">{ "Repeat Password" }</label>
<input
aria-describedby="password-check-feedback"
aria-describedby="new-password-check-feedback"
autocomplete="new-password"
class={ pw_check_class }
id="password-check"
id="new-password-check"
oninput={
ctx.link()
.callback(move |_| {

View file

@ -14,6 +14,7 @@ use super::passkey::PasskeyModalApp;
use super::passkeyremove::PasskeyRemoveModalApp;
use super::pwmodal::PwModalApp;
use super::totpmodal::TotpModalApp;
use super::totpremove::TotpRemoveComp;
use crate::error::*;
use crate::{models, utils};
@ -32,6 +33,13 @@ pub struct ModalProps {
pub cb: Callback<EventBusMsg>,
}
#[derive(PartialEq, Properties)]
pub struct TotpRemoveProps {
pub token: CUSessionToken,
pub label: String,
pub cb: Callback<EventBusMsg>,
}
#[derive(PartialEq, Properties)]
pub struct PasskeyRemoveModalProps {
pub token: CUSessionToken,
@ -354,15 +362,68 @@ impl CredentialResetApp {
html! {
<>
<p>{ "✅ Password Set" }</p>
<p>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#staticPassword">
{ "Change Password" }
</button>
</p>
<p>{ "❌ MFA Disabled" }</p>
<p>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#staticTotpCreate">
{ "Add TOTP" }
</button>
</p>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#staticTotpCreate">
{ "Add TOTP" }
</button>
<p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#staticDeletePrimaryCred">
{ "Delete this Insecure Password" }
</button>
</p>
</>
}
}
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! {
<>
<p>{ "✅ Password Set" }</p>
<p>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#staticPassword">
{ "Change Password" }
</button>
</p>
<p>{ "✅ MFA Enabled" }</p>
<>
{ for totp_set.iter()
.map(|detail| html! { <TotpRemoveComp token={ token.clone() } label={ detail.clone() } cb={ cb.clone() } /> })
}
</>
<p>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#staticTotpCreate">
{ "Add TOTP" }
</button>
</p>
<p>
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#staticDeletePrimaryCred">
{ "Delete this Legacy MFA Credential" }
</button>
</p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#staticDeletePrimaryCred">
{ "Delete this Password" }
</button>
</>
}
}
@ -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! {
<>
<p>{ "✅ Password Set" }</p>
<p>{ "✅ MFA Enabled" }</p>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#staticTotpCreate">
{ "Reset TOTP" }
</button>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#staticDeletePrimaryCred">
{ "Delete this MFA Credential" }
</button>
</>
}
}
None => {
html! {
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#staticPassword">
@ -479,7 +514,7 @@ impl CredentialResetApp {
<h4>{"Password / TOTP"}</h4>
<p>{ "Legacy password paired with other authentication factors." }</p>
<p>{ "It is recommended you avoid setting these if possible." }</p>
<p>{ "It is recommended you avoid setting these if possible, as these can be phished or exploited." }</p>
{ pw_html }
<hr class="my-4" />

View file

@ -164,12 +164,17 @@ impl Component for TotpModalApp {
// Send off the submit, lock the form.
// default is empty str
let totp = utils::get_value_from_element_id("totp").unwrap_or_default();
let totp_label = utils::get_value_from_element_id("totp-label").unwrap_or_default();
match totp.trim().parse::<u32>() {
Ok(totp) => {
ctx.link().send_future(async move {
match Self::submit_totp_update(token_c, CURequest::TotpVerify(totp), cb)
.await
match Self::submit_totp_update(
token_c,
CURequest::TotpVerify(totp, totp_label),
cb,
)
.await
{
Ok(v) => v,
Err(v) => v.into(),
@ -398,6 +403,22 @@ impl Component for TotpModalApp {
value={secret.to_uri()}
/>
<label for="totp-label" class="form-label">{ "Enter a name for your TOTP" }</label>
<input
class="form-control"
id="totp-label"
oninput={
ctx.link()
.callback(move |_| {
Msg::TotpClearInvalid
})
}
placeholder=""
required=true
type="text"
/>
<label for="totp" class="form-label">{ "Enter a TOTP code to confirm it's working" }</label>
<input
aria-describedby="totp-validation-feedback"

View file

@ -0,0 +1,147 @@
use super::reset::{EventBusMsg, TotpRemoveProps};
#[cfg(debug_assertions)]
use gloo::console;
use kanidm_proto::v1::{CURequest, CUSessionToken, CUStatus};
use wasm_bindgen::{JsCast, JsValue, UnwrapThrowExt};
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response};
use crate::error::*;
use crate::utils;
use yew::prelude::*;
pub enum Msg {
Submit,
Success,
Error { emsg: String, kopid: Option<String> },
}
impl From<FetchError> for Msg {
fn from(fe: FetchError) -> Self {
Msg::Error {
emsg: fe.as_string(),
kopid: None,
}
}
}
pub struct TotpRemoveComp {
enabled: bool,
}
impl Component for TotpRemoveComp {
type Message = Msg;
type Properties = TotpRemoveProps;
fn create(_ctx: &Context<Self>) -> Self {
#[cfg(debug_assertions)]
console::debug!("totp remove::create");
TotpRemoveComp { enabled: true }
}
fn changed(&mut self, _ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
#[cfg(debug_assertions)]
console::debug!("totp remove::change");
false
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
#[cfg(debug_assertions)]
console::debug!("totp remove::update");
let cb = ctx.props().cb.clone();
match msg {
Msg::Submit => {
let token_c = ctx.props().token.clone();
let label = ctx.props().label.clone();
ctx.link().send_future(async {
match Self::submit_totp_update(token_c, CURequest::TotpRemove(label), cb).await
{
Ok(v) => v,
Err(v) => v.into(),
}
});
self.enabled = false;
}
Msg::Success => {
// Do nothing, very well.
}
Msg::Error { emsg, kopid } => {
// Submit the error to the parent.
cb.emit(EventBusMsg::Error { emsg, kopid });
}
}
true
}
fn view(&self, ctx: &Context<Self>) -> Html {
let label = ctx.props().label.clone();
let submit_enabled = self.enabled;
html! {
<div class="row mb-3">
<div class="col">{ label }</div>
<div class="col">
<button type="button" class="btn btn-dark btn-sml"
disabled={ !submit_enabled }
onclick={
ctx.link()
.callback(move |_| Msg::Submit)
}
>
{ "Remove TOTP" }
</button>
</div>
</div>
}
}
}
impl TotpRemoveComp {
async fn submit_totp_update(
token: CUSessionToken,
req: CURequest,
cb: Callback<EventBusMsg>,
) -> Result<Msg, FetchError> {
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 })
}
}
}

View file

@ -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 }
/>
</div>
@ -333,6 +334,9 @@ impl LoginApp {
LoginAppMsg::PasswordSubmit
} ) }
>
<div>
<input hidden=true type="text" autocomplete="username" />
</div>
<div class={CLASS_DIV_LOGIN_FIELD}>
<input
autofocus=true
@ -342,6 +346,7 @@ impl LoginApp {
name="password"
oninput={ ctx.link().callback(|e: InputEvent| LoginAppMsg::Input(utils::get_value_from_input_event(e))) }
type="password"
autocomplete="current-password"
value={ inputvalue }
/>
</div>
@ -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 }
/>
</div>
@ -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 }
/>
</div>
@ -977,5 +984,10 @@ impl Component for LoginApp {
fn rendered(&mut self, _ctx: &Context<Self>, _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");
}
}