Add client UX for redirecting to an external portal for synced accounts ()

This commit is contained in:
Firstyear 2023-07-05 09:13:06 +10:00 committed by GitHub
parent 9d462b4b00
commit 17fa61ceeb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1227 additions and 321 deletions

View file

@ -1,6 +1,7 @@
use crate::{ClientError, KanidmClient};
use kanidm_proto::v1::Entry;
use std::collections::BTreeMap;
use url::Url;
impl KanidmClient {
pub async fn idm_sync_account_list(&self) -> Result<Vec<Entry>, ClientError> {
@ -12,6 +13,35 @@ impl KanidmClient {
.await
}
pub async fn idm_sync_account_set_credential_portal(
&self,
id: &str,
url: Option<&Url>,
) -> Result<(), ClientError> {
let m = if let Some(url) = url {
vec![url.to_owned()]
} else {
vec![]
};
self.perform_put_request(
format!("/v1/sync_account/{}/_attr/sync_credential_portal", id).as_str(),
m,
)
.await
}
pub async fn idm_sync_account_get_credential_portal(
&self,
id: &str,
) -> Result<Option<Url>, ClientError> {
self.perform_get_request(
format!("/v1/sync_account/{}/_attr/sync_credential_portal", id).as_str(),
)
.await
.map(|values: Vec<Url>| values.get(0).map(|u| u.clone()))
}
pub async fn idm_sync_account_create(
&self,
name: &str,

View file

@ -1,5 +1,7 @@
use crate::v1::ApiTokenPurpose;
use serde::{Deserialize, Serialize};
use url::Url;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, Clone)]
/// This is a description of a linked or connected application for a user. This is
@ -13,3 +15,14 @@ pub enum AppLink {
icon: Option<Url>,
},
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub struct ScimSyncToken {
// uuid of the token?
pub token_id: Uuid,
#[serde(with = "time::serde::timestamp")]
pub issued_at: time::OffsetDateTime,
#[serde(default)]
pub purpose: ApiTokenPurpose,
}

View file

@ -2,6 +2,7 @@ use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::str::FromStr;
use url::Url;
use num_enum::TryFromPrimitive;
use serde::{Deserialize, Serialize};
@ -309,6 +310,7 @@ pub enum UiHint {
ExperimentalFeatures = 0,
PosixAccount = 1,
CredentialUpdate = 2,
SynchronisedAccount = 3,
}
impl fmt::Display for UiHint {
@ -317,6 +319,7 @@ impl fmt::Display for UiHint {
UiHint::PosixAccount => write!(f, "PosixAccount"),
UiHint::CredentialUpdate => write!(f, "CredentialUpdate"),
UiHint::ExperimentalFeatures => write!(f, "ExperimentalFeatures"),
UiHint::SynchronisedAccount => write!(f, "SynchronisedAccount"),
}
}
}
@ -329,6 +332,7 @@ impl FromStr for UiHint {
"CredentialUpdate" => Ok(UiHint::CredentialUpdate),
"PosixAccount" => Ok(UiHint::PosixAccount),
"ExperimentalFeatures" => Ok(UiHint::ExperimentalFeatures),
"SynchronisedAccount" => Ok(UiHint::SynchronisedAccount),
_ => Err(()),
}
}
@ -408,7 +412,6 @@ pub struct UserAuthToken {
pub displayname: String,
pub spn: String,
pub mail_primary: Option<String>,
// pub groups: Vec<Group>,
pub ui_hints: BTreeSet<UiHint>,
}
@ -431,11 +434,6 @@ impl fmt::Display for UserAuthToken {
writeln!(f, "purpose: read write (expiry: none)")?
}
}
/*
for group in &self.groups {
writeln!(f, "group: {:?}", group.spn)?;
}
*/
Ok(())
}
}
@ -1147,14 +1145,27 @@ pub enum CURegState {
Passkey(CreationChallengeResponse),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CUExtPortal {
None,
Hidden,
Some(Url),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CUStatus {
// Display values
pub spn: String,
pub displayname: String,
pub ext_cred_portal: CUExtPortal,
// Internal State Tracking
pub mfaregstate: CURegState,
// Display hints + The credential details.
pub can_commit: bool,
pub primary: Option<CredentialDetail>,
pub primary_can_edit: bool,
pub passkeys: Vec<PasskeyDetail>,
pub mfaregstate: CURegState,
pub passkeys_can_edit: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]

View file

@ -4,7 +4,10 @@ use kanidm_proto::scim_v1::ScimSyncRequest;
use kanidm_proto::v1::Entry as ProtoEntry;
use kanidmd_lib::prelude::*;
use super::v1::{json_rest_event_get, json_rest_event_get_id, json_rest_event_post};
use super::v1::{
json_rest_event_get, json_rest_event_get_id, json_rest_event_get_id_attr, json_rest_event_post,
json_rest_event_put_id_attr,
};
pub async fn sync_account_get(req: tide::Request<AppState>) -> tide::Result {
let filter = filter_all!(f_eq("class", PartialValue::new_class("sync_account")));
@ -21,6 +24,16 @@ pub async fn sync_account_id_get(req: tide::Request<AppState>) -> tide::Result {
json_rest_event_get_id(req, filter, None).await
}
pub async fn sync_account_id_get_attr(req: tide::Request<AppState>) -> tide::Result {
let filter = filter_all!(f_eq("class", PartialValue::new_class("sync_account")));
json_rest_event_get_id_attr(req, filter).await
}
pub async fn sync_account_id_put_attr(req: tide::Request<AppState>) -> tide::Result {
let filter = filter_all!(f_eq("class", PartialValue::new_class("sync_account")));
json_rest_event_put_id_attr(req, filter).await
}
pub async fn sync_account_id_patch(mut req: tide::Request<AppState>) -> tide::Result {
// Update a value / attrs
let uat = req.get_current_uat();
@ -264,6 +277,11 @@ pub fn scim_route_setup(appserver: &mut tide::Route<'_, AppState>, routemap: &mu
.mapped_get(routemap, sync_account_id_get)
.mapped_patch(routemap, sync_account_id_patch);
sync_account_route
.at("/:id/_attr/:attr")
.mapped_get(routemap, sync_account_id_get_attr)
.mapped_put(routemap, sync_account_id_put_attr);
sync_account_route
.at("/:id/_finalise")
.mapped_get(routemap, sync_account_id_get_finalise);

View file

@ -25,12 +25,26 @@ pub struct DbCidV1 {
#[derive(Serialize, Deserialize, Debug)]
pub enum DbValueIntentTokenStateV1 {
#[serde(rename = "v")]
Valid { max_ttl: Duration },
Valid {
max_ttl: Duration,
#[serde(default)]
ext_cred_portal_can_view: bool,
#[serde(default)]
primary_can_edit: bool,
#[serde(default)]
passkeys_can_edit: bool,
},
#[serde(rename = "p")]
InProgress {
max_ttl: Duration,
session_id: Uuid,
session_ttl: Duration,
#[serde(default)]
ext_cred_portal_can_view: bool,
#[serde(default)]
primary_can_edit: bool,
#[serde(default)]
passkeys_can_edit: bool,
},
#[serde(rename = "c")]
Consumed { max_ttl: Duration },

View file

@ -23,6 +23,7 @@ lazy_static! {
Value::new_json_filter_s("{\"eq\": [\"class\", \"recycled\"]}")
.expect("Invalid JSON filter")
),
("acp_search_attr", Value::new_iutf8("class")),
("acp_search_attr", Value::new_iutf8("name")),
("acp_search_attr", Value::new_iutf8("class")),
("acp_search_attr", Value::new_iutf8("uuid")),
@ -70,6 +71,7 @@ lazy_static! {
"acp_targetscope",
Value::new_json_filter_s("\"self\"").expect("Invalid JSON filter")
),
("acp_search_attr", Value::new_iutf8("class")),
("acp_search_attr", Value::new_iutf8("name")),
("acp_search_attr", Value::new_iutf8("spn")),
("acp_search_attr", Value::new_iutf8("displayname")),
@ -81,6 +83,7 @@ lazy_static! {
("acp_search_attr", Value::new_iutf8("gidnumber")),
("acp_search_attr", Value::new_iutf8("loginshell")),
("acp_search_attr", Value::new_iutf8("uuid")),
("acp_search_attr", Value::new_iutf8("sync_parent_uuid")),
("acp_search_attr", Value::new_iutf8("account_expire")),
("acp_search_attr", Value::new_iutf8("account_valid_from")),
("acp_search_attr", Value::new_iutf8("primary_credential")),
@ -209,10 +212,11 @@ lazy_static! {
(
"acp_targetscope",
Value::new_json_filter_s(
"{\"and\": [{\"pres\": \"class\"}, {\"andnot\": {\"or\": [{\"eq\": [\"class\", \"tombstone\"]}, {\"eq\": [\"class\", \"recycled\"]}]}}]}"
"{\"and\": [{\"or\": [{\"eq\": [\"class\",\"account\"]}, {\"eq\": [\"class\",\"group\"]}]}, {\"andnot\": {\"or\": [{\"eq\": [\"class\", \"tombstone\"]}, {\"eq\": [\"class\", \"recycled\"]}]}}]}"
)
.expect("Invalid JSON filter")
),
("acp_search_attr", Value::new_iutf8("class")),
("acp_search_attr", Value::new_iutf8("name")),
("acp_search_attr", Value::new_iutf8("spn")),
("acp_search_attr", Value::new_iutf8("displayname")),
@ -248,6 +252,7 @@ lazy_static! {
)
.expect("Invalid JSON filter")
),
("acp_search_attr", Value::new_iutf8("class")),
("acp_search_attr", Value::new_iutf8("name")),
("acp_search_attr", Value::new_iutf8("displayname")),
("acp_search_attr", Value::new_iutf8("legalname")),
@ -549,6 +554,7 @@ lazy_static! {
)
.expect("Invalid JSON filter")
),
("acp_search_attr", Value::new_iutf8("class")),
("acp_search_attr", Value::new_iutf8("name")),
("acp_search_attr", Value::new_iutf8("uuid")),
("acp_search_attr", Value::new_iutf8("spn")),
@ -772,6 +778,7 @@ lazy_static! {
)
.expect("Invalid JSON filter")
),
("acp_search_attr", Value::new_iutf8("class")),
("acp_search_attr", Value::new_iutf8("name")),
("acp_search_attr", Value::new_iutf8("spn")),
("acp_search_attr", Value::new_iutf8("uuid")),
@ -886,6 +893,7 @@ lazy_static! {
)
.expect("Invalid JSON filter")
),
("acp_search_attr", Value::new_iutf8("class")),
("acp_search_attr", Value::new_iutf8("name")),
("acp_search_attr", Value::new_iutf8("uuid")),
("acp_search_attr", Value::new_iutf8("spn")),
@ -1231,6 +1239,7 @@ lazy_static! {
)
.expect("Invalid JSON filter")
),
("acp_search_attr", Value::new_iutf8("class")),
("acp_search_attr", Value::new_iutf8("name")),
("acp_search_attr", Value::new_iutf8("uuid")),
("acp_search_attr", Value::new_iutf8("domain_display_name")),
@ -1276,6 +1285,7 @@ lazy_static! {
)
.expect("Invalid JSON filter")
),
("acp_search_attr", Value::new_iutf8("class")),
("acp_search_attr", Value::new_iutf8("name")),
("acp_search_attr", Value::new_iutf8("uuid")),
("acp_search_attr", Value::new_iutf8("description")),
@ -1589,19 +1599,23 @@ lazy_static! {
.expect("Invalid JSON filter")
),
("acp_search_attr", Value::new_iutf8("class")),
("acp_search_attr", Value::new_iutf8("uuid")),
("acp_search_attr", Value::new_iutf8("name")),
("acp_search_attr", Value::new_iutf8("description")),
("acp_search_attr", Value::new_iutf8("jws_es256_private_key")),
("acp_search_attr", Value::new_iutf8("sync_token_session")),
("acp_search_attr", Value::new_iutf8("sync_credential_portal")),
("acp_search_attr", Value::new_iutf8("sync_cookie")),
("acp_modify_removedattr", Value::new_iutf8("name")),
("acp_modify_removedattr", Value::new_iutf8("description")),
("acp_modify_removedattr", Value::new_iutf8("jws_es256_private_key")),
("acp_modify_removedattr", Value::new_iutf8("sync_token_session")),
("acp_modify_removedattr", Value::new_iutf8("sync_cookie")),
("acp_modify_removedattr", Value::new_iutf8("sync_credential_portal")),
("acp_modify_presentattr", Value::new_iutf8("name")),
("acp_modify_presentattr", Value::new_iutf8("description")),
("acp_modify_presentattr", Value::new_iutf8("sync_token_session")),
("acp_modify_presentattr", Value::new_iutf8("sync_credential_portal")),
("acp_create_attr", Value::new_iutf8("class")),
("acp_create_attr", Value::new_iutf8("name")),
("acp_create_attr", Value::new_iutf8("description")),

View file

@ -1,6 +1,11 @@
// Core
// Schema uuids start at 00000000-0000-0000-0000-ffff00000000
use crate::constants::uuids::*;
use crate::constants::values::*;
use crate::entry::{Entry, EntryInit, EntryInitNew, EntryNew};
use crate::value::{SyntaxType, Value};
// system supplementary
pub const JSON_SCHEMA_ATTR_DISPLAYNAME: &str = r#"{
"attrs": {
@ -1457,6 +1462,23 @@ pub const JSON_SCHEMA_ATTR_GRANT_UI_HINT: &str = r#"{
}
}"#;
lazy_static! {
pub static ref E_SCHEMA_ATTR_SYNC_CREDENTIAL_PORTAL: EntryInitNew = entry_init!(
("class", CLASS_OBJECT.clone()),
("class", CLASS_SYSTEM.clone()),
("class", CLASS_ATTRIBUTETYPE.clone()),
(
"description",
Value::new_utf8s("The url of an external credential portal for synced accounts to visit to update their credentials.")
),
("unique", Value::Bool(false)),
("multivalue", Value::Bool(false)),
("attributename", Value::new_iutf8("sync_credential_portal")),
("syntax", Value::Syntax(SyntaxType::Url)),
("uuid", Value::Uuid(UUID_SCHEMA_ATTR_SYNC_CREDENTIAL_PORTAL))
);
}
// === classes ===
pub const JSON_SCHEMA_CLASS_PERSON: &str = r#"
@ -1685,7 +1707,8 @@ pub const JSON_SCHEMA_CLASS_SYNC_ACCOUNT: &str = r#"
],
"systemmay": [
"sync_token_session",
"sync_cookie"
"sync_cookie",
"sync_credential_portal"
],
"uuid": [
"00000000-0000-0000-0000-ffff00000114"

View file

@ -229,6 +229,9 @@ pub const _UUID_SCHEMA_ATTR_DOMAIN_LDAP_BASEDN: Uuid =
pub const UUID_SCHEMA_ATTR_DYNMEMBER: Uuid = uuid!("00000000-0000-0000-0000-ffff00000132");
pub const UUID_SCHEMA_ATTR_NAME_HISTORY: Uuid = uuid!("00000000-0000-0000-0000-ffff00000133");
pub const UUID_SCHEMA_ATTR_SYNC_CREDENTIAL_PORTAL: Uuid =
uuid!("00000000-0000-0000-0000-ffff00000136");
// System and domain infos
// I'd like to strongly criticise william of the past for making poor choices about these allocations.
pub const UUID_SYSTEM_INFO: Uuid = uuid!("00000000-0000-0000-0000-ffffff000001");

View file

@ -55,6 +55,7 @@ lazy_static! {
pub static ref CLASS_PERSON: Value = Value::new_class("person");
pub static ref CLASS_RECYCLED: Value = Value::new_class("recycled");
pub static ref CLASS_SERVICE_ACCOUNT: Value = Value::new_class("service_account");
pub static ref CLASS_SYNC_ACCOUNT: Value = Value::new_class("sync_account");
pub static ref CLASS_SYNC_OBJECT: Value = Value::new_class("sync_object");
pub static ref CLASS_SYSTEM: Value = Value::new_class("system");
pub static ref CLASS_SYSTEM_CONFIG: Value = Value::new_class("system_config");

View file

@ -47,6 +47,8 @@ macro_rules! try_from_entry {
"Missing attribute: displayname".to_string(),
))?;
let sync_parent_uuid = $value.get_ava_single_refer("sync_parent_uuid");
let primary = $value
.get_ava_single_credential("primary_credential")
.map(|v| v.clone());
@ -98,10 +100,15 @@ macro_rules! try_from_entry {
.copied()
.collect();
if !$value.attribute_equality("class", &PVCLASS_SYNC_OBJECT) {
// For now disable cred updates on sync accounts too.
if $value.attribute_equality("class", &PVCLASS_PERSON) {
ui_hints.insert(UiHint::CredentialUpdate);
}
if $value.attribute_equality("class", &PVCLASS_SYNC_OBJECT) {
ui_hints.insert(UiHint::SynchronisedAccount);
}
if $value.attribute_equality("class", &PVCLASS_POSIXACCOUNT) {
ui_hints.insert(UiHint::PosixAccount);
}
@ -109,6 +116,7 @@ macro_rules! try_from_entry {
Ok(Account {
uuid,
name,
sync_parent_uuid,
displayname,
groups,
primary,
@ -137,6 +145,7 @@ pub struct Account {
pub name: String,
pub displayname: String,
pub uuid: Uuid,
pub sync_parent_uuid: Option<Uuid>,
// We want to allow this so that in the future we can populate this into oauth2 tokens
#[allow(dead_code)]
pub groups: Vec<Group>,

View file

@ -6,7 +6,8 @@ use std::time::Duration;
use hashbrown::HashSet;
use kanidm_proto::v1::{
CURegState, CUStatus, CredentialDetail, PasskeyDetail, PasswordFeedback, TotpSecret,
CUExtPortal, CURegState, CUStatus, CredentialDetail, PasskeyDetail, PasswordFeedback,
TotpSecret,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
@ -22,7 +23,7 @@ use crate::idm::server::{IdmServerCredUpdateTransaction, IdmServerProxyWriteTran
use crate::prelude::*;
use crate::server::access::Access;
use crate::utils::{backup_code_from_random, readable_password_from_random, uuid_from_duration};
use crate::value::IntentTokenState;
use crate::value::{CredUpdateSessionPerms, IntentTokenState};
const MAXIMUM_CRED_UPDATE_TTL: Duration = Duration::from_secs(900);
const MAXIMUM_INTENT_TTL: Duration = Duration::from_secs(86400);
@ -84,13 +85,20 @@ pub(crate) struct CredentialUpdateSession {
intent_token_id: Option<String>,
// Acc policy
// Is there an extertal credential portal?
ext_cred_portal: CUExtPortal,
// The pw credential as they are being updated
primary: Option<Credential>,
primary_can_edit: bool,
// Passkeys that have been configured.
passkeys: BTreeMap<Uuid, (String, PasskeyV4)>,
passkeys_can_edit: bool,
// Devicekeys
_devicekeys: BTreeMap<Uuid, (String, DeviceKeyV4)>,
_devicekeys_can_edit: bool,
// Internal reg state of any inprogress totp or webauthn credentials.
mfaregstate: MfaRegState,
@ -177,12 +185,14 @@ pub struct CredentialUpdateSessionStatus {
spn: String,
// The target user's display name
displayname: String,
// ttl: Duration,
can_commit: bool,
primary: Option<CredentialDetail>,
passkeys: Vec<PasskeyDetail>,
ext_cred_portal: CUExtPortal,
// Any info the client needs about mfareg state.
mfaregstate: MfaRegStateStatus,
can_commit: bool,
primary: Option<CredentialDetail>,
primary_can_edit: bool,
passkeys: Vec<PasskeyDetail>,
passkeys_can_edit: bool,
}
impl CredentialUpdateSessionStatus {
@ -201,11 +211,9 @@ impl CredentialUpdateSessionStatus {
impl Into<CUStatus> for CredentialUpdateSessionStatus {
fn into(self) -> CUStatus {
CUStatus {
spn: self.spn.clone(),
displayname: self.displayname.clone(),
can_commit: self.can_commit,
primary: self.primary,
passkeys: self.passkeys,
spn: self.spn,
displayname: self.displayname,
ext_cred_portal: self.ext_cred_portal,
mfaregstate: match self.mfaregstate {
MfaRegStateStatus::None => CURegState::None,
MfaRegStateStatus::TotpCheck(c) => CURegState::TotpCheck(c),
@ -216,6 +224,11 @@ impl Into<CUStatus> for CredentialUpdateSessionStatus {
}
MfaRegStateStatus::Passkey(r) => CURegState::Passkey(r),
},
can_commit: self.can_commit,
primary: self.primary,
primary_can_edit: self.primary_can_edit,
passkeys: self.passkeys,
passkeys_can_edit: self.passkeys_can_edit,
}
}
}
@ -225,8 +238,10 @@ impl From<&CredentialUpdateSession> for CredentialUpdateSessionStatus {
CredentialUpdateSessionStatus {
spn: session.account.spn.clone(),
displayname: session.account.displayname.clone(),
ext_cred_portal: session.ext_cred_portal.clone(),
can_commit: session.can_commit(),
primary: session.primary.as_ref().map(|c| c.into()),
primary_can_edit: session.primary_can_edit,
passkeys: session
.passkeys
.iter()
@ -235,6 +250,7 @@ impl From<&CredentialUpdateSession> for CredentialUpdateSessionStatus {
uuid: *uuid,
})
.collect(),
passkeys_can_edit: session.passkeys_can_edit,
mfaregstate: match &session.mfaregstate {
MfaRegState::None => MfaRegStateStatus::None,
MfaRegState::TotpInit(token) => MfaRegStateStatus::TotpCheck(
@ -309,11 +325,10 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
&mut self,
target: Uuid,
ident: &Identity,
) -> Result<Account, OperationError> {
) -> Result<(Account, CredUpdateSessionPerms), OperationError> {
let entry = self.qs_write.internal_search_uuid(target)?;
security_info!(
%entry,
%target,
"Initiating Credential Update Session",
);
@ -336,8 +351,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
ident,
Some(btreeset![
AttrString::from("primary_credential"),
AttrString::from("passkeys"),
AttrString::from("devicekeys")
AttrString::from("passkeys")
]),
&[entry],
)?;
@ -373,16 +387,71 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
Access::Allow(attrs) => attrs.contains("primary_credential"),
};
if !eperm_search_primary_cred || !eperm_mod_primary_cred || !eperm_rem_primary_cred {
security_info!(
"Requester {} does not have permission to update credentials of {}",
ident,
account.spn
);
return Err(OperationError::NotAuthorised);
}
let primary_can_edit =
eperm_search_primary_cred && eperm_mod_primary_cred && eperm_rem_primary_cred;
Ok(account)
let eperm_search_passkeys = match &eperm.search {
Access::Denied => false,
Access::Grant => true,
Access::Allow(attrs) => attrs.contains("passkeys"),
};
let eperm_mod_passkeys = match &eperm.modify_pres {
Access::Denied => false,
Access::Grant => true,
Access::Allow(attrs) => attrs.contains("passkeys"),
};
let eperm_rem_passkeys = match &eperm.modify_rem {
Access::Denied => false,
Access::Grant => true,
Access::Allow(attrs) => attrs.contains("passkeys"),
};
let passkeys_can_edit = eperm_search_passkeys && eperm_mod_passkeys && eperm_rem_passkeys;
let ext_cred_portal_can_view = if let Some(sync_parent_uuid) = account.sync_parent_uuid {
// In theory this is always granted due to how access controls work, but we check anyway.
let entry = self.qs_write.internal_search_uuid(sync_parent_uuid)?;
let effective_perms = self
.qs_write
.get_accesscontrols()
.effective_permission_check(
ident,
Some(btreeset![AttrString::from("sync_credential_portal")]),
&[entry],
)?;
let eperm = effective_perms.get(0).ok_or_else(|| {
admin_error!("Effective Permission check returned no results");
OperationError::InvalidState
})?;
match &eperm.search {
Access::Denied => false,
Access::Grant => true,
Access::Allow(attrs) => attrs.contains("sync_credential_portal"),
}
} else {
false
};
// At lease *one* must be modifiable OR visible.
if !(primary_can_edit || passkeys_can_edit || ext_cred_portal_can_view) {
error!("Unable to proceed with credential update intent - at least one type of credential must be modifiable or visible.");
return Err(OperationError::NotAuthorised);
} else {
security_info!(%primary_can_edit, %passkeys_can_edit, %ext_cred_portal_can_view, "Proceeding");
Ok((
account,
CredUpdateSessionPerms {
ext_cred_portal_can_view,
passkeys_can_edit,
primary_can_edit,
},
))
}
}
fn create_credupdate_session(
@ -390,12 +459,43 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
sessionid: Uuid,
intent_token_id: Option<String>,
account: Account,
perms: &CredUpdateSessionPerms,
ct: Duration,
) -> Result<(CredentialUpdateSessionToken, CredentialUpdateSessionStatus), OperationError> {
let ext_cred_portal_can_view = perms.ext_cred_portal_can_view;
let primary_can_edit = perms.primary_can_edit;
let passkeys_can_edit = perms.passkeys_can_edit;
// - stash the current state of all associated credentials
let primary = account.primary.clone();
let passkeys = account.passkeys.clone();
let devicekeys = account.devicekeys.clone();
let primary = if primary_can_edit {
account.primary.clone()
} else {
None
};
let passkeys = if passkeys_can_edit {
account.passkeys.clone()
} else {
BTreeMap::default()
};
// let devicekeys = account.devicekeys.clone();
let devicekeys = BTreeMap::default();
// Get the external credential portal, if any.
let ext_cred_portal = match (account.sync_parent_uuid, ext_cred_portal_can_view) {
(Some(sync_parent_uuid), true) => {
let sync_entry = self.qs_write.internal_search_uuid(sync_parent_uuid)?;
sync_entry
.get_ava_single_url("sync_credential_portal")
.cloned()
.map(CUExtPortal::Some)
.unwrap_or(CUExtPortal::Hidden)
}
(Some(_), false) => CUExtPortal::Hidden,
(None, _) => CUExtPortal::None,
};
// Stash the issuer for some UI elements
let issuer = self.qs_write.get_domain_display_name().to_string();
@ -404,9 +504,13 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
account,
issuer,
intent_token_id,
ext_cred_portal,
primary,
primary_can_edit,
passkeys,
passkeys_can_edit,
_devicekeys: devicekeys,
_devicekeys_can_edit: false,
mfaregstate: MfaRegState::None,
};
@ -444,7 +548,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
event: &InitCredentialUpdateIntentEvent,
ct: Duration,
) -> Result<CredentialUpdateIntentToken, OperationError> {
let account = self.validate_init_credential_update(event.target, &event.ident)?;
let (account, perms) = self.validate_init_credential_update(event.target, &event.ident)?;
// ==== AUTHORISATION CHECKED ===
@ -479,7 +583,10 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
// occurs.
let mut modlist = ModifyList::new_append(
"credential_update_intent_token",
Value::IntentToken(intent_id.clone(), IntentTokenState::Valid { max_ttl }),
Value::IntentToken(
intent_id.clone(),
IntentTokenState::Valid { max_ttl, perms },
),
);
// Remove any old credential update intents
@ -488,9 +595,10 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
.iter()
.for_each(|(existing_intent_id, state)| {
let max_ttl = match state {
IntentTokenState::Valid { max_ttl }
IntentTokenState::Valid { max_ttl, perms: _ }
| IntentTokenState::InProgress {
max_ttl,
perms: _,
session_id: _,
session_ttl: _,
}
@ -596,7 +704,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
// Check there is not already a user session in progress with this intent token.
// Is there a need to revoke intent tokens?
let max_ttl = match account.credential_update_intent_tokens.get(&intent_id) {
let (max_ttl, perms) = match account.credential_update_intent_tokens.get(&intent_id) {
Some(IntentTokenState::Consumed { max_ttl: _ }) => {
security_info!(
%entry,
@ -607,6 +715,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
}
Some(IntentTokenState::InProgress {
max_ttl,
perms,
session_id,
session_ttl,
}) => {
@ -633,9 +742,9 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
"Initiating Update Session - Intent Token was in use {} - this will be invalidated.", session_id
);
};
*max_ttl
(*max_ttl, *perms)
}
Some(IntentTokenState::Valid { max_ttl }) => {
Some(IntentTokenState::Valid { max_ttl, perms }) => {
// Check the TTL
if current_time >= *max_ttl {
trace!(?current_time, ?max_ttl);
@ -647,7 +756,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
%account.uuid,
"Initiating Credential Update Session",
);
*max_ttl
(*max_ttl, *perms)
}
}
None => {
@ -679,6 +788,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
intent_id.clone(),
IntentTokenState::InProgress {
max_ttl,
perms,
session_id,
session_ttl: current_time + MAXIMUM_CRED_UPDATE_TTL,
},
@ -699,7 +809,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
// ==========
// Okay, good to exchange.
self.create_credupdate_session(session_id, Some(intent_id), account, current_time)
self.create_credupdate_session(session_id, Some(intent_id), account, &perms, current_time)
}
#[instrument(level = "debug", skip_all)]
@ -708,14 +818,15 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
event: &InitCredentialUpdateEvent,
ct: Duration,
) -> Result<(CredentialUpdateSessionToken, CredentialUpdateSessionStatus), OperationError> {
let account = self.validate_init_credential_update(event.target, &event.ident)?;
let (account, perms) = self.validate_init_credential_update(event.target, &event.ident)?;
// ==== AUTHORISATION CHECKED ===
// This is the expiry time, so that our cleanup task can "purge up to now" rather
// than needing to do calculations.
let sessionid = uuid_from_duration(ct + MAXIMUM_CRED_UPDATE_TTL, self.sid);
// Build the cred update session.
self.create_credupdate_session(sessionid, None, account, ct)
self.create_credupdate_session(sessionid, None, account, &perms, ct)
}
#[instrument(level = "trace", skip(self))]
@ -815,6 +926,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
let max_ttl = match account.credential_update_intent_tokens.get(intent_token_id) {
Some(IntentTokenState::InProgress {
max_ttl,
perms: _,
session_id,
session_ttl: _,
}) => {
@ -826,7 +938,10 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
}
}
Some(IntentTokenState::Consumed { max_ttl: _ })
| Some(IntentTokenState::Valid { max_ttl: _ })
| Some(IntentTokenState::Valid {
max_ttl: _,
perms: _,
})
| None => {
security_info!("Session originated from an intent token, but the intent token has transitioned to an invalid state. Refusing to commit changes.");
return Err(OperationError::InvalidState);
@ -846,43 +961,51 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
));
};
match &session.primary {
Some(ncred) => {
modlist.push_mod(Modify::Purged(AttrString::from("primary_credential")));
let vcred = Value::new_credential("primary", ncred.clone());
modlist.push_mod(Modify::Present(
AttrString::from("primary_credential"),
vcred,
));
}
None => {
modlist.push_mod(Modify::Purged(AttrString::from("primary_credential")));
}
if session.primary_can_edit {
match &session.primary {
Some(ncred) => {
modlist.push_mod(Modify::Purged(AttrString::from("primary_credential")));
let vcred = Value::new_credential("primary", ncred.clone());
modlist.push_mod(Modify::Present(
AttrString::from("primary_credential"),
vcred,
));
}
None => {
modlist.push_mod(Modify::Purged(AttrString::from("primary_credential")));
}
};
};
// Need to update passkeys.
modlist.push_mod(Modify::Purged(AttrString::from("passkeys")));
// Add all the passkeys. If none, nothing will be added! This handles
// the delete case quite cleanly :)
session.passkeys.iter().for_each(|(uuid, (tag, pk))| {
let v_pk = Value::Passkey(*uuid, tag.clone(), pk.clone());
modlist.push_mod(Modify::Present(AttrString::from("passkeys"), v_pk));
});
// Are any other checks needed?
if session.passkeys_can_edit {
// Need to update passkeys.
modlist.push_mod(Modify::Purged(AttrString::from("passkeys")));
// Add all the passkeys. If none, nothing will be added! This handles
// the delete case quite cleanly :)
session.passkeys.iter().for_each(|(uuid, (tag, pk))| {
let v_pk = Value::Passkey(*uuid, tag.clone(), pk.clone());
modlist.push_mod(Modify::Present(AttrString::from("passkeys"), v_pk));
});
};
// Apply to the account!
trace!(?modlist, "processing change");
self.qs_write
.internal_modify(
// Filter as executed
&filter!(f_eq("uuid", PartialValue::Uuid(session.account.uuid))),
&modlist,
)
.map_err(|e| {
request_error!(error = ?e);
e
})
if modlist.is_empty() {
trace!("no changes to apply");
Ok(())
} else {
self.qs_write
.internal_modify(
// Filter as executed
&filter!(f_eq("uuid", PartialValue::Uuid(session.account.uuid))),
&modlist,
)
.map_err(|e| {
request_error!(error = ?e);
e
})
}
}
pub fn cancel_credential_update(
@ -898,9 +1021,13 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
let entry = self.qs_write.internal_search_uuid(session.account.uuid)?;
let account = Account::try_from_entry_rw(entry.as_ref(), &mut self.qs_write)?;
let max_ttl = match account.credential_update_intent_tokens.get(intent_token_id) {
let (max_ttl, perms) = match account
.credential_update_intent_tokens
.get(intent_token_id)
{
Some(IntentTokenState::InProgress {
max_ttl,
perms,
session_id,
session_ttl: _,
}) => {
@ -908,11 +1035,14 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
security_info!("Session originated from an intent token, but the intent token has initiated a conflicting second update session. Refusing to commit changes.");
return Err(OperationError::InvalidState);
} else {
*max_ttl
(*max_ttl, *perms)
}
}
Some(IntentTokenState::Consumed { max_ttl: _ })
| Some(IntentTokenState::Valid { max_ttl: _ })
| Some(IntentTokenState::Valid {
max_ttl: _,
perms: _,
})
| None => {
security_info!("Session originated from an intent token, but the intent token has transitioned to an invalid state. Refusing to commit changes.");
return Err(OperationError::InvalidState);
@ -925,7 +1055,10 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
));
modlist.push_mod(Modify::Present(
AttrString::from("credential_update_intent_token"),
Value::IntentToken(intent_token_id.clone(), IntentTokenState::Valid { max_ttl }),
Value::IntentToken(
intent_token_id.clone(),
IntentTokenState::Valid { max_ttl, perms },
),
));
};
@ -1164,6 +1297,11 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
})?;
trace!(?session);
if !session.primary_can_edit {
error!("Session does not have permission to modify primary credential");
return Err(OperationError::AccessDenied);
};
// Check pw quality (future - acc policy applies).
self.check_password_quality(pw, session.account.related_inputs().as_slice())
.map_err(|e| match e {
@ -1200,6 +1338,11 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
})?;
trace!(?session);
if !session.primary_can_edit {
error!("Session does not have permission to modify primary credential");
return Err(OperationError::AccessDenied);
};
// Is there something else in progress?
// Or should this just cancel it ....
if !matches!(session.mfaregstate, MfaRegState::None) {
@ -1229,6 +1372,11 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
})?;
trace!(?session);
if !session.primary_can_edit {
error!("Session does not have permission to modify primary credential");
return Err(OperationError::AccessDenied);
};
// Are we in a totp reg state?
match &session.mfaregstate {
MfaRegState::TotpInit(totp_token)
@ -1288,6 +1436,11 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
})?;
trace!(?session);
if !session.primary_can_edit {
error!("Session does not have permission to modify primary credential");
return Err(OperationError::AccessDenied);
};
// Are we in a totp reg state?
match &session.mfaregstate {
MfaRegState::TotpInvalidSha1(_, token_sha1, label) => {
@ -1326,6 +1479,11 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
})?;
trace!(?session);
if !session.primary_can_edit {
error!("Session does not have permission to modify primary credential");
return Err(OperationError::AccessDenied);
};
if !matches!(session.mfaregstate, MfaRegState::None) {
admin_info!("Invalid TOTP state, another update is in progress");
return Err(OperationError::InvalidState);
@ -1359,6 +1517,11 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
})?;
trace!(?session);
if !session.primary_can_edit {
error!("Session does not have permission to modify primary credential");
return Err(OperationError::AccessDenied);
};
// I think we override/map the status to inject the codes as a once-off state message.
let codes = backup_code_from_random();
@ -1399,6 +1562,11 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
})?;
trace!(?session);
if !session.primary_can_edit {
error!("Session does not have permission to modify primary credential");
return Err(OperationError::AccessDenied);
};
let ncred = session
.primary
.as_ref()
@ -1432,6 +1600,11 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
})?;
trace!(?session);
if !session.passkeys_can_edit {
error!("Session does not have permission to modify primary credential");
return Err(OperationError::AccessDenied);
};
if !matches!(session.mfaregstate, MfaRegState::None) {
admin_info!("Invalid Passkey Init state, another update is in progress");
return Err(OperationError::InvalidState);
@ -1469,6 +1642,11 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
})?;
trace!(?session);
if !session.passkeys_can_edit {
error!("Session does not have permission to modify primary credential");
return Err(OperationError::AccessDenied);
};
match &session.mfaregstate {
MfaRegState::Passkey(_ccr, pk_reg) => {
let passkey = self
@ -1503,6 +1681,11 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
})?;
trace!(?session);
if !session.passkeys_can_edit {
error!("Session does not have permission to modify primary credential");
return Err(OperationError::AccessDenied);
};
// No-op if not present
session.passkeys.remove(&uuid);
@ -1535,6 +1718,12 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
OperationError::InvalidState
})?;
trace!(?session);
if !session.primary_can_edit {
error!("Session does not have permission to modify primary credential");
return Err(OperationError::AccessDenied);
};
session.primary = None;
Ok(session.deref().into())
}
@ -1546,7 +1735,9 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
mod tests {
use std::time::Duration;
use kanidm_proto::v1::{AuthAllowed, AuthIssueSession, AuthMech, CredentialDetailType};
use kanidm_proto::v1::{
AuthAllowed, AuthIssueSession, AuthMech, CUExtPortal, CredentialDetailType,
};
use uuid::uuid;
use webauthn_authenticator_rs::softpasskey::SoftPasskey;
use webauthn_authenticator_rs::WebauthnAuthenticator;
@ -2536,6 +2727,152 @@ mod tests {
);
}
#[idm_test]
async fn test_idm_credential_update_access_denied(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
// Test that if access is denied for a synced account, that the actual action to update
// the credentials is always denied.
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let mut idms_prox_write = idms.proxy_write(ct).await;
let sync_uuid = Uuid::new_v4();
let e1 = entry_init!(
("class", Value::new_class("object")),
("class", Value::new_class("sync_account")),
("name", Value::new_iname("test_scim_sync")),
("uuid", Value::Uuid(sync_uuid)),
("description", Value::new_utf8s("A test sync agreement"))
);
let e2 = entry_init!(
("class", Value::new_class("object")),
("class", Value::new_class("sync_object")),
("class", Value::new_class("account")),
("class", Value::new_class("person")),
("sync_parent_uuid", Value::Refer(sync_uuid)),
("name", Value::new_iname("testperson")),
("uuid", Value::Uuid(TESTPERSON_UUID)),
("description", Value::new_utf8s("testperson")),
("displayname", Value::new_utf8s("testperson"))
);
let ce = CreateEvent::new_internal(vec![e1, e2]);
let cr = idms_prox_write.qs_write.create(&ce);
assert!(cr.is_ok());
let testperson = idms_prox_write
.qs_write
.internal_search_uuid(TESTPERSON_UUID)
.expect("failed");
let cur = idms_prox_write.init_credential_update(
&InitCredentialUpdateEvent::new_impersonate_entry(testperson),
ct,
);
idms_prox_write.commit().expect("Failed to commit txn");
let (cust, custatus) = cur.expect("Failed to start update");
trace!(?custatus);
// Destructure to force us to update this test if we change this
// structure at all.
let CredentialUpdateSessionStatus {
spn: _,
displayname: _,
ext_cred_portal,
mfaregstate: _,
can_commit: _,
primary: _,
primary_can_edit,
passkeys: _,
passkeys_can_edit,
} = custatus;
assert!(matches!(ext_cred_portal, CUExtPortal::Hidden));
assert!(!primary_can_edit);
assert!(!passkeys_can_edit);
let cutxn = idms.cred_update_transaction().await;
// let origin = cutxn.get_origin().clone();
// Test that any of the primary or passkey update methods fail with access denied.
// credential_primary_set_password
let err = cutxn
.credential_primary_set_password(&cust, ct, "password")
.unwrap_err();
assert!(matches!(err, OperationError::AccessDenied));
// credential_primary_init_totp
let err = cutxn.credential_primary_init_totp(&cust, ct).unwrap_err();
assert!(matches!(err, OperationError::AccessDenied));
// credential_primary_check_totp
let err = cutxn
.credential_primary_check_totp(&cust, ct, 0, "totp")
.unwrap_err();
assert!(matches!(err, OperationError::AccessDenied));
// credential_primary_accept_sha1_totp
let err = cutxn
.credential_primary_accept_sha1_totp(&cust, ct)
.unwrap_err();
assert!(matches!(err, OperationError::AccessDenied));
// credential_primary_remove_totp
let err = cutxn
.credential_primary_remove_totp(&cust, ct, "totp")
.unwrap_err();
assert!(matches!(err, OperationError::AccessDenied));
// credential_primary_init_backup_codes
let err = cutxn
.credential_primary_init_backup_codes(&cust, ct)
.unwrap_err();
assert!(matches!(err, OperationError::AccessDenied));
// credential_primary_remove_backup_codes
let err = cutxn
.credential_primary_remove_backup_codes(&cust, ct)
.unwrap_err();
assert!(matches!(err, OperationError::AccessDenied));
// credential_primary_delete
let err = cutxn.credential_primary_delete(&cust, ct).unwrap_err();
assert!(matches!(err, OperationError::AccessDenied));
// credential_passkey_init
let err = cutxn.credential_passkey_init(&cust, ct).unwrap_err();
assert!(matches!(err, OperationError::AccessDenied));
// credential_passkey_finish
// Can't test because we need a public key response.
// credential_passkey_remove
let err = cutxn
.credential_passkey_remove(&cust, ct, Uuid::new_v4())
.unwrap_err();
assert!(matches!(err, OperationError::AccessDenied));
let c_status = cutxn
.credential_update_status(&cust, ct)
.expect("Failed to get the current session status.");
trace!(?c_status);
assert!(c_status.primary.is_none());
assert!(c_status.passkeys.is_empty());
drop(cutxn);
commit_session(idms, ct, cust).await;
}
// W_ policy, assert can't remove MFA if it's enforced.
// enroll trusted device

View file

@ -3,9 +3,9 @@ use std::time::Duration;
use base64urlsafedata::Base64UrlSafeData;
use compact_jwt::{Jws, JwsSigner};
use kanidm_proto::internal::ScimSyncToken;
use kanidm_proto::scim_v1::*;
use kanidm_proto::v1::ApiTokenPurpose;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use crate::credential::totp::{Totp, TotpAlgo, TotpDigits};
@ -116,17 +116,6 @@ impl GenerateScimSyncTokenEvent {
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub(crate) struct ScimSyncToken {
// uuid of the token?
pub token_id: Uuid,
#[serde(with = "time::serde::timestamp")]
pub issued_at: time::OffsetDateTime,
#[serde(default)]
pub purpose: ApiTokenPurpose,
}
impl<'a> IdmServerProxyWriteTransaction<'a> {
pub fn scim_sync_generate_token(
&mut self,
@ -189,7 +178,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
.and_then(|_| {
// The modify succeeded and was allowed, now sign the token for return.
token
.sign(&sync_account.jws_key)
.sign_embed_public_jwk(&sync_account.jws_key)
.map(|jws_signed| jws_signed.to_string())
.map_err(|e| {
admin_error!(err = ?e, "Unable to sign sync token");

View file

@ -12,6 +12,7 @@ use concread::hashmap::HashMap;
use concread::CowCell;
use fernet::Fernet;
use hashbrown::HashSet;
use kanidm_proto::internal::ScimSyncToken;
use kanidm_proto::v1::{
ApiToken, BackupCodesView, CredentialStatus, PasswordFeedback, RadiusAuthToken, UatPurpose,
UnixGroupToken, UnixUserToken, UserAuthToken,
@ -49,7 +50,7 @@ use crate::idm::oauth2::{
Oauth2ResourceServersWriteTransaction,
};
use crate::idm::radius::RadiusAccount;
use crate::idm::scim::{ScimSyncToken, SyncAccount};
use crate::idm::scim::SyncAccount;
use crate::idm::serviceaccount::ServiceAccount;
use crate::idm::unix::{UnixGroup, UnixUserAccount};
use crate::idm::AuthState;

View file

@ -150,12 +150,24 @@ pub enum ReplIntentTokenV1 {
Valid {
token_id: String,
max_ttl: Duration,
#[serde(default)]
ext_cred_portal_can_view: bool,
#[serde(default)]
primary_can_edit: bool,
#[serde(default)]
passkeys_can_edit: bool,
},
InProgress {
token_id: String,
max_ttl: Duration,
session_id: Uuid,
session_ttl: Duration,
#[serde(default)]
ext_cred_portal_can_view: bool,
#[serde(default)]
primary_can_edit: bool,
#[serde(default)]
passkeys_can_edit: bool,
},
Consumed {
token_id: String,

View file

@ -2677,4 +2677,86 @@ mod tests {
// Check the deny case.
test_acp_search!(&se_b, vec![], r_set, ex_b);
}
#[test]
fn test_access_sync_account_dyn_search() {
sketching::test_init();
// Test that an account that has been synchronised from external
// sources is able to read the sync providers credential portal
// url.
let sync_uuid = Uuid::new_v4();
let portal_url = Url::parse("https://localhost/portal").unwrap();
let ev1 = unsafe {
entry_init!(
("class", CLASS_OBJECT.clone()),
("class", CLASS_SYNC_ACCOUNT.clone()),
("uuid", Value::Uuid(sync_uuid)),
("name", Value::new_iname("test_sync_account")),
("sync_credential_portal", Value::Url(portal_url.clone()))
)
.into_sealed_committed()
};
let ev1_reduced = unsafe {
entry_init!(
("class", CLASS_OBJECT.clone()),
("class", CLASS_SYNC_ACCOUNT.clone()),
("uuid", Value::Uuid(sync_uuid)),
("sync_credential_portal", Value::Url(portal_url.clone()))
)
.into_sealed_committed()
};
let ev2 = unsafe {
entry_init!(
("class", CLASS_OBJECT.clone()),
("class", CLASS_SYNC_ACCOUNT.clone()),
("uuid", Value::Uuid(Uuid::new_v4())),
("name", Value::new_iname("test_sync_account")),
("sync_credential_portal", Value::Url(portal_url.clone()))
)
.into_sealed_committed()
};
let sync_test_account: Arc<EntrySealedCommitted> = Arc::new(unsafe {
entry_init!(
("class", CLASS_OBJECT.clone()),
("class", CLASS_ACCOUNT.clone()),
("class", CLASS_SYNC_OBJECT.clone()),
("name", Value::new_iname("test_account_1")),
("uuid", Value::Uuid(UUID_TEST_ACCOUNT_1)),
("memberof", Value::Refer(UUID_TEST_GROUP_1)),
("sync_parent_uuid", Value::Refer(sync_uuid))
)
.into_sealed_committed()
});
// Check the authorised search event, and that it reduces correctly.
let r_set = vec![Arc::new(ev1.clone()), Arc::new(ev2)];
let se_a = unsafe {
SearchEvent::new_impersonate_entry(
sync_test_account,
filter_all!(f_pres("sync_credential_portal")),
)
};
let ex_a = vec![Arc::new(ev1)];
let ex_a_reduced = vec![ev1_reduced];
test_acp_search!(&se_a, vec![], r_set.clone(), ex_a);
test_acp_search_reduce!(&se_a, vec![], r_set.clone(), ex_a_reduced);
// Test a non-synced account aka the deny case
let se_b = unsafe {
SearchEvent::new_impersonate_entry(
E_TEST_ACCOUNT_2.clone(),
filter_all!(f_pres("sync_credential_portal")),
)
};
let ex_b = vec![];
test_acp_search!(&se_b, vec![], r_set, ex_b);
}
}

View file

@ -42,6 +42,14 @@ pub(super) fn apply_search_access<'a>(
AccessResult::Allow(mut set) => allow.append(&mut set),
};
match search_sync_account_filter_entry(ident, entry) {
AccessResult::Denied => denied = true,
AccessResult::Grant => grant = true,
AccessResult::Ignore => {}
AccessResult::Constrain(mut set) => constrain.append(&mut set),
AccessResult::Allow(mut set) => allow.append(&mut set),
};
// We'll add more modules later.
// Now finalise the decision.
@ -125,6 +133,7 @@ fn search_oauth2_filter_entry<'a>(
set.contains("oauth2_resource_server")
})
.unwrap_or(false);
let contains_o2_scope_member = entry
.get_ava_as_oauthscopemaps("oauth2_rs_scope_map")
.and_then(|maps| ident.get_memberof().map(|mo| (maps, mo)))
@ -147,3 +156,56 @@ fn search_oauth2_filter_entry<'a>(
}
}
}
fn search_sync_account_filter_entry<'a>(
ident: &Identity,
entry: &'a Arc<EntrySealedCommitted>,
) -> AccessResult<'a> {
match &ident.origin {
IdentType::Internal | IdentType::Synch(_) => AccessResult::Ignore,
IdentType::User(iuser) => {
// Is the user a synced object?
let is_user_sync_account = iuser
.entry
.get_ava_as_iutf8("class")
.map(|set| {
trace!(?set);
set.contains("sync_object") && set.contains("account")
})
.unwrap_or(false);
if is_user_sync_account {
let is_target_sync_account = entry
.get_ava_as_iutf8("class")
.map(|set| {
trace!(?set);
set.contains("sync_account")
})
.unwrap_or(false);
if is_target_sync_account {
// Okay, now we need to check if the uuids line up.
let sync_uuid = entry.get_uuid();
let sync_source_match = iuser
.entry
.get_ava_single_refer("sync_parent_uuid")
.map(|sync_parent_uuid| sync_parent_uuid == sync_uuid)
.unwrap_or(false);
if sync_source_match {
// We finally got here!
security_access!(entry = ?entry.get_uuid(), ident = ?iuser.entry.get_uuid2rdn(), "ident is a synchronsied account from this sync account");
return AccessResult::Allow(btreeset!(
"class",
"uuid",
"sync_credential_portal"
));
}
}
}
// Fall through
AccessResult::Ignore
}
}
}

View file

@ -417,6 +417,18 @@ impl<'a> QueryServerWriteTransaction<'a> {
#[instrument(level = "debug", skip_all)]
pub fn initialise_schema_idm(&mut self) -> Result<(), OperationError> {
admin_debug!("initialise_schema_idm -> start ...");
let idm_schema_attrs = [E_SCHEMA_ATTR_SYNC_CREDENTIAL_PORTAL.clone()];
let r: Result<(), _> = idm_schema_attrs
.into_iter()
.try_for_each(|entry| self.internal_migrate_or_create(entry));
if !r.is_ok() {
error!(res = ?r, "initialise_schema_idm -> Error");
}
debug_assert!(r.is_ok());
// List of IDM schemas to init.
let idm_schema: Vec<&str> = vec![
JSON_SCHEMA_ATTR_DISPLAYNAME,
@ -487,11 +499,11 @@ impl<'a> QueryServerWriteTransaction<'a> {
.try_for_each(|e_str| self.internal_migrate_or_create_str(e_str));
if r.is_ok() {
admin_debug!("initialise_schema_idm -> Ok!");
debug!("initialise_schema_idm -> Ok!");
} else {
admin_error!(res = ?r, "initialise_schema_idm -> Error");
error!(res = ?r, "initialise_schema_idm -> Error");
}
debug_assert!(r.is_ok()); // why return a result if we assert it's `Ok`?
debug_assert!(r.is_ok());
r
}

View file

@ -113,13 +113,22 @@ pub struct Address {
pub country: String,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct CredUpdateSessionPerms {
pub ext_cred_portal_can_view: bool,
pub primary_can_edit: bool,
pub passkeys_can_edit: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IntentTokenState {
Valid {
max_ttl: Duration,
perms: CredUpdateSessionPerms,
},
InProgress {
max_ttl: Duration,
perms: CredUpdateSessionPerms,
session_id: Uuid,
session_ttl: Duration,
},

View file

@ -12,7 +12,8 @@ use crate::repl::proto::{
ReplAttrV1, ReplCredV1, ReplDeviceKeyV4V1, ReplIntentTokenV1, ReplPasskeyV4V1,
};
use crate::schema::SchemaAttribute;
use crate::valueset::{DbValueSetV2, IntentTokenState, ValueSet};
use crate::value::{CredUpdateSessionPerms, IntentTokenState};
use crate::valueset::{DbValueSetV2, ValueSet};
#[derive(Debug, Clone)]
pub struct ValueSetCredential {
@ -215,17 +216,35 @@ impl ValueSetIntentToken {
.into_iter()
.map(|(s, dits)| {
let ts = match dits {
DbValueIntentTokenStateV1::Valid { max_ttl } => {
IntentTokenState::Valid { max_ttl }
}
DbValueIntentTokenStateV1::Valid {
max_ttl,
ext_cred_portal_can_view,
primary_can_edit,
passkeys_can_edit,
} => IntentTokenState::Valid {
max_ttl,
perms: CredUpdateSessionPerms {
ext_cred_portal_can_view,
primary_can_edit,
passkeys_can_edit,
},
},
DbValueIntentTokenStateV1::InProgress {
max_ttl,
session_id,
session_ttl,
ext_cred_portal_can_view,
primary_can_edit,
passkeys_can_edit,
} => IntentTokenState::InProgress {
max_ttl,
session_id,
session_ttl,
perms: CredUpdateSessionPerms {
ext_cred_portal_can_view,
primary_can_edit,
passkeys_can_edit,
},
},
DbValueIntentTokenStateV1::Consumed { max_ttl } => {
IntentTokenState::Consumed { max_ttl }
@ -241,21 +260,42 @@ impl ValueSetIntentToken {
let map = data
.iter()
.map(|dits| match dits {
ReplIntentTokenV1::Valid { token_id, max_ttl } => (
ReplIntentTokenV1::Valid {
token_id,
max_ttl,
ext_cred_portal_can_view,
primary_can_edit,
passkeys_can_edit,
} => (
token_id.clone(),
IntentTokenState::Valid { max_ttl: *max_ttl },
IntentTokenState::Valid {
max_ttl: *max_ttl,
perms: CredUpdateSessionPerms {
ext_cred_portal_can_view: *ext_cred_portal_can_view,
primary_can_edit: *primary_can_edit,
passkeys_can_edit: *passkeys_can_edit,
},
},
),
ReplIntentTokenV1::InProgress {
token_id,
max_ttl,
session_id,
session_ttl,
ext_cred_portal_can_view,
primary_can_edit,
passkeys_can_edit,
} => (
token_id.clone(),
IntentTokenState::InProgress {
max_ttl: *max_ttl,
session_id: *session_id,
session_ttl: *session_ttl,
perms: CredUpdateSessionPerms {
ext_cred_portal_can_view: *ext_cred_portal_can_view,
primary_can_edit: *primary_can_edit,
passkeys_can_edit: *passkeys_can_edit,
},
},
),
ReplIntentTokenV1::Consumed { token_id, max_ttl } => (
@ -350,17 +390,37 @@ impl ValueSetT for ValueSetIntentToken {
(
u.clone(),
match s {
IntentTokenState::Valid { max_ttl } => {
DbValueIntentTokenStateV1::Valid { max_ttl: *max_ttl }
}
IntentTokenState::Valid {
max_ttl,
perms:
CredUpdateSessionPerms {
ext_cred_portal_can_view,
primary_can_edit,
passkeys_can_edit,
},
} => DbValueIntentTokenStateV1::Valid {
max_ttl: *max_ttl,
ext_cred_portal_can_view: *ext_cred_portal_can_view,
primary_can_edit: *primary_can_edit,
passkeys_can_edit: *passkeys_can_edit,
},
IntentTokenState::InProgress {
max_ttl,
session_id,
session_ttl,
perms:
CredUpdateSessionPerms {
ext_cred_portal_can_view,
primary_can_edit,
passkeys_can_edit,
},
} => DbValueIntentTokenStateV1::InProgress {
max_ttl: *max_ttl,
session_id: *session_id,
session_ttl: *session_ttl,
ext_cred_portal_can_view: *ext_cred_portal_can_view,
primary_can_edit: *primary_can_edit,
passkeys_can_edit: *passkeys_can_edit,
},
IntentTokenState::Consumed { max_ttl } => {
DbValueIntentTokenStateV1::Consumed { max_ttl: *max_ttl }
@ -378,19 +438,39 @@ impl ValueSetT for ValueSetIntentToken {
.map
.iter()
.map(|(u, s)| match s {
IntentTokenState::Valid { max_ttl } => ReplIntentTokenV1::Valid {
IntentTokenState::Valid {
max_ttl,
perms:
CredUpdateSessionPerms {
ext_cred_portal_can_view,
primary_can_edit,
passkeys_can_edit,
},
} => ReplIntentTokenV1::Valid {
token_id: u.clone(),
max_ttl: *max_ttl,
ext_cred_portal_can_view: *ext_cred_portal_can_view,
primary_can_edit: *primary_can_edit,
passkeys_can_edit: *passkeys_can_edit,
},
IntentTokenState::InProgress {
max_ttl,
session_id,
session_ttl,
perms:
CredUpdateSessionPerms {
ext_cred_portal_can_view,
primary_can_edit,
passkeys_can_edit,
},
} => ReplIntentTokenV1::InProgress {
token_id: u.clone(),
max_ttl: *max_ttl,
session_id: *session_id,
session_ttl: *session_ttl,
ext_cred_portal_can_view: *ext_cred_portal_can_view,
primary_can_edit: *primary_can_edit,
passkeys_can_edit: *passkeys_can_edit,
},
IntentTokenState::Consumed { max_ttl } => ReplIntentTokenV1::Consumed {
token_id: u.clone(),

View file

@ -1,5 +1,9 @@
use compact_jwt::JwsUnverified;
use kanidm_client::KanidmClient;
use kanidm_proto::internal::ScimSyncToken;
use kanidmd_testkit::ADMIN_TEST_PASSWORD;
use std::str::FromStr;
use url::Url;
#[kanidmd_testkit::test]
async fn test_sync_account_lifecycle(rsclient: KanidmClient) {
@ -23,21 +27,64 @@ async fn test_sync_account_lifecycle(rsclient: KanidmClient) {
.idm_sync_account_get("ipa_sync_account")
.await
.unwrap();
assert!(a.is_some());
println!("{:?}", a);
let sync_entry = a.expect("No sync account was created?!");
// Shouldn't have a cred portal.
assert!(!sync_entry.attrs.contains_key("sync_credential_portal"));
let url = Url::parse("https://sink.ipa.example.com/reset").unwrap();
// Set our credential portal.
rsclient
.idm_sync_account_set_credential_portal("ipa_sync_account", Some(&url))
.await
.unwrap();
let a = rsclient
.idm_sync_account_get("ipa_sync_account")
.await
.unwrap();
let sync_entry = a.expect("No sync account present?");
// Should have a cred portal.
let url_a = sync_entry
.attrs
.get("sync_credential_portal")
.and_then(|x| x.get(0));
assert_eq!(
url_a.map(|s| s.as_str()),
Some("https://sink.ipa.example.com/reset")
);
// Also check we can get it direct
let url_b = rsclient
.idm_sync_account_get_credential_portal("ipa_sync_account")
.await
.unwrap();
assert_eq!(url_b, Some(url));
// Get a token
let token = rsclient
.idm_sync_account_generate_token("ipa_sync_account", "token_label")
.await
.expect("Failed to generate token");
// List sessions?
let token_unverified = JwsUnverified::from_str(&token).expect("Failed to parse apitoken");
// Reset Sign Key
// Get New token
let token: ScimSyncToken = token_unverified
.validate_embeded()
.map(|j| j.into_inner())
.expect("Embedded jwk not found");
// Get sync state
println!("{:?}", token);
// Delete session
// Sync state fails.
// Delete account
rsclient
.idm_sync_account_destroy_token("ipa_sync_account")
.await
.expect("Failed to destroy token");
}

View file

@ -27,7 +27,7 @@ repository = "https://github.com/kanidm/kanidm/"
crate-type = ["cdylib", "rlib"]
[dependencies]
compact_jwt = { workspace = true, default-features = false, features = ["unsafe_release_without_verify"] }
compact_jwt = { workspace = true }
# gloo = "^0.8.0"
gloo = { workspace = true }
js-sys = { workspace = true }

View file

@ -234,7 +234,7 @@ function addBorrowedObject(obj) {
}
function __wbg_adapter_48(arg0, arg1, arg2) {
try {
wasm._dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hd11765386bfbbb1b(arg0, arg1, addBorrowedObject(arg2));
wasm._dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h6570e9fcbe2dd992(arg0, arg1, addBorrowedObject(arg2));
} finally {
heap[stack_pointer++] = undefined;
}
@ -242,14 +242,14 @@ function __wbg_adapter_48(arg0, arg1, arg2) {
function __wbg_adapter_51(arg0, arg1, arg2) {
try {
wasm._dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h13182848512d5ace(arg0, arg1, addBorrowedObject(arg2));
wasm._dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h9a25baf5f77e5e3e(arg0, arg1, addBorrowedObject(arg2));
} finally {
heap[stack_pointer++] = undefined;
}
}
function __wbg_adapter_54(arg0, arg1, arg2) {
wasm._dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h9e05cfbc2276f207(arg0, arg1, addHeapObject(arg2));
wasm._dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hb285c11f4a69b963(arg0, arg1, addHeapObject(arg2));
}
/**
@ -1118,16 +1118,16 @@ function __wbg_get_imports() {
const ret = wasm.memory;
return addHeapObject(ret);
};
imports.wbg.__wbindgen_closure_wrapper4628 = function(arg0, arg1, arg2) {
const ret = makeMutClosure(arg0, arg1, 1074, __wbg_adapter_48);
imports.wbg.__wbindgen_closure_wrapper4655 = function(arg0, arg1, arg2) {
const ret = makeMutClosure(arg0, arg1, 1076, __wbg_adapter_48);
return addHeapObject(ret);
};
imports.wbg.__wbindgen_closure_wrapper5401 = function(arg0, arg1, arg2) {
const ret = makeMutClosure(arg0, arg1, 1357, __wbg_adapter_51);
imports.wbg.__wbindgen_closure_wrapper5440 = function(arg0, arg1, arg2) {
const ret = makeMutClosure(arg0, arg1, 1368, __wbg_adapter_51);
return addHeapObject(ret);
};
imports.wbg.__wbindgen_closure_wrapper6520 = function(arg0, arg1, arg2) {
const ret = makeMutClosure(arg0, arg1, 1431, __wbg_adapter_54);
imports.wbg.__wbindgen_closure_wrapper6559 = function(arg0, arg1, arg2) {
const ret = makeMutClosure(arg0, arg1, 1442, __wbg_adapter_54);
return addHeapObject(ret);
};

View file

@ -1,6 +1,7 @@
use gloo::console;
use kanidm_proto::v1::{
CUIntentToken, CUSessionToken, CUStatus, CredentialDetail, CredentialDetailType,
CUExtPortal, CUIntentToken, CUSessionToken, CUStatus, CredentialDetail, CredentialDetailType,
PasskeyDetail,
};
use uuid::Uuid;
use wasm_bindgen::{JsValue, UnwrapThrowExt};
@ -343,144 +344,42 @@ impl CredentialResetApp {
fn view_main(&self, ctx: &Context<Self>, token: &CUSessionToken, status: &CUStatus) -> Html {
remove_body_form_classes!();
let displayname = status.displayname.clone();
let spn = status.spn.clone();
let CUStatus {
spn,
displayname,
ext_cred_portal,
mfaregstate: _,
can_commit,
primary,
primary_can_edit,
passkeys,
passkeys_can_edit,
} = status;
let displayname = displayname.clone();
let spn = spn.clone();
let cb = self.cb.clone();
let can_commit = status.can_commit;
// match on primary, get type_.
// FUTURE: Need to work out based on policy if this is shown!
let pw_html = match &status.primary {
Some(CredentialDetail {
uuid: _,
type_: CredentialDetailType::Password,
}) => {
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>
<TotpModalApp token={ token.clone() } cb={ cb.clone() }/>
</p>
<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>
<TotpModalApp token={ token.clone() } cb={ cb.clone() }/>
</p>
<p>
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#staticDeletePrimaryCred">
{ "Delete this Legacy MFA Credential" }
</button>
</p>
</>
}
}
Some(CredentialDetail {
uuid: _,
type_: CredentialDetailType::GeneratedPassword,
}) => {
html! {
<>
<p>{ "Generated Password" }</p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#staticDeletePrimaryCred">
{ "Delete this Password" }
</button>
</>
}
}
Some(CredentialDetail {
uuid: _,
type_: CredentialDetailType::Passkey(_),
}) => {
html! {
<>
<p>{ "Webauthn Only - Will migrate to Passkeys in a future update" }</p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#staticDeletePrimaryCred">
{ "Delete this Credential" }
</button>
</>
}
}
None => {
html! {
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#staticPassword">
{ "Add Password" }
</button>
}
}
};
let passkey_html = if status.passkeys.is_empty() {
html! {
<p>{ "No Passkeys Registered" }</p>
}
} else {
html! {
let ext_cred_portal_html = match ext_cred_portal {
CUExtPortal::None => html! { <></> },
CUExtPortal::Hidden => html! {
<>
{ for status.passkeys.iter()
.map(|detail|
PasskeyRemoveModalApp::render_button(&detail.tag, detail.uuid)
)
}
<p>{ "This account is externally managed. Some features may not be available." }</p>
</>
},
CUExtPortal::Some(url) => {
let url_str = url.as_str().to_string();
html! {
<>
<p>{ "This account is externally managed. Some features may not be available." }</p>
<a href={ url_str } >{ "Visit the external account portal" }</a>
</>
}
}
};
let passkey_modals_html = html! {
<>
{ for status.passkeys.iter()
.map(|detail|
html! { <PasskeyRemoveModalApp token={ token.clone() } tag={ detail.tag.clone() } uuid={ detail.uuid } cb={ cb.clone() } /> }
)
}
</>
};
let pw_html = self.view_primary(token, primary, *primary_can_edit);
let passkey_html = self.view_passkeys(token, passkeys, *passkeys_can_edit);
html! {
<>
@ -495,18 +394,15 @@ impl CredentialResetApp {
<div class="row g-3">
<form class="needs-validation" novalidate=true>
<hr class="my-4" />
<h4>{"Passkeys"}</h4>
<p>{ "Strong cryptographic authenticators with self contained multi-factor authentication." }</p>
{ passkey_html }
<PasskeyModalApp token={ token.clone() } cb={ cb.clone() } />
{ ext_cred_portal_html }
<hr class="my-4" />
{ passkey_html }
<hr class="my-4" />
<h4>{"Password / TOTP"}</h4>
<p>{ "Legacy password paired with other authentication factors." }</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" />
@ -535,19 +431,198 @@ impl CredentialResetApp {
</div>
</main>
<PwModalApp token={ token.clone() } cb={ cb.clone() } />
<DeleteApp token= { token.clone() } cb={ cb.clone() }/>
{ passkey_modals_html }
</div>
{ crate::utils::do_footer() }
</>
}
}
fn view_primary(
&self,
token: &CUSessionToken,
primary: &Option<CredentialDetail>,
primary_can_edit: bool,
) -> Html {
let cb = self.cb.clone();
// match on primary, get type_.
let pw_html_inner = if primary_can_edit {
match primary {
Some(CredentialDetail {
uuid: _,
type_: CredentialDetailType::Password,
}) => {
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>
<TotpModalApp token={ token.clone() } cb={ cb.clone() }/>
</p>
<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>
<TotpModalApp token={ token.clone() } cb={ cb.clone() }/>
</p>
<p>
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#staticDeletePrimaryCred">
{ "Delete this Legacy MFA Credential" }
</button>
</p>
</>
}
}
Some(CredentialDetail {
uuid: _,
type_: CredentialDetailType::GeneratedPassword,
}) => {
html! {
<>
<p>{ "Generated Password" }</p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#staticDeletePrimaryCred">
{ "Delete this Password" }
</button>
</>
}
}
Some(CredentialDetail {
uuid: _,
type_: CredentialDetailType::Passkey(_),
}) => {
html! {
<>
<p>{ "Webauthn Only - Will migrate to Passkeys in a future update" }</p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#staticDeletePrimaryCred">
{ "Delete this Credential" }
</button>
</>
}
}
None => {
html! {
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#staticPassword">
{ "Add Password" }
</button>
}
}
}
} else {
html! {<></>}
};
let pw_warn = if primary_can_edit {
html! {
<>
<p>{ "Legacy password paired with other authentication factors." }</p>
<p>{ "It is recommended you avoid setting these if possible, as these can be phished or exploited." }</p>
</>
}
} else {
html! { <><p> { "You do not have access to modify the Password or TOTP tokens of this account" }</p></> }
};
html! {
<>
<h4>{"Password / TOTP"}</h4>
{ pw_warn }
{ pw_html_inner }
<PwModalApp token={ token.clone() } cb={ cb.clone() } />
</>
}
}
fn view_passkeys(
&self,
token: &CUSessionToken,
passkeys: &Vec<PasskeyDetail>,
passkeys_can_edit: bool,
) -> Html {
let cb = self.cb.clone();
let passkey_html_inner = if !passkeys_can_edit {
html! { <><p> { "You do not have access to modify the Passkeys of this account" }</p></> }
} else if passkeys.is_empty() {
html! {
<>
<p>{ "Strong cryptographic authenticators with self contained multi-factor authentication." }</p>
<p>{ "No Passkeys Registered" }</p>
<PasskeyModalApp token={ token.clone() } cb={ cb.clone() } />
</>
}
} else {
html! {
<>
<p>{ "Strong cryptographic authenticators with self contained multi-factor authentication." }</p>
{ for passkeys.iter()
.map(|detail|
PasskeyRemoveModalApp::render_button(&detail.tag, detail.uuid)
)
}
{ for passkeys.iter()
.map(|detail|
html! { <PasskeyRemoveModalApp token={ token.clone() } tag={ detail.tag.clone() } uuid={ detail.uuid } cb={ cb.clone() } /> }
)
}
<PasskeyModalApp token={ token.clone() } cb={ cb.clone() } />
</>
}
};
html! {
<>
<h4>{"Passkeys"}</h4>
{ passkey_html_inner }
</>
}
}
fn view_error(&self, _ctx: &Context<Self>, msg: &str, kopid: Option<&str>) -> Html {
html! {
<main class="form-signin">

View file

@ -225,7 +225,6 @@ impl ViewsApp {
let current_user_uat = uat.clone();
let ui_hint_experimental = uat.ui_hints.contains(&UiHint::ExperimentalFeatures);
let credential_update = uat.ui_hints.contains(&UiHint::CredentialUpdate);
// WARN set dash-body against body here?
html! {
@ -249,14 +248,12 @@ impl ViewsApp {
</Link<ViewRoute>>
</li>
if credential_update {
<li class="mb-1">
<li class="mb-1">
<Link<ViewRoute> classes="nav-link" to={ViewRoute::Profile}>
<span data-feather="file"></span>
{ "Profile" }
</Link<ViewRoute>>
</li>
}
</li>
if ui_hint_experimental {
<li class="mb-1">

View file

@ -195,6 +195,11 @@ impl Component for ProfileApp {
impl ProfileApp {
fn view_profile(&self, ctx: &Context<Self>, submit_enabled: bool, uat: UserAuthToken) -> Html {
// Get ui hints.
// Until we do finegrained updates in the cred update, we disable credupdates for some
// account classes.
html! {
<>
<div>
@ -205,27 +210,32 @@ impl ProfileApp {
</button>
</div>
<hr/>
<div>
<p>
<button type="button" class="btn btn-primary"
disabled={ !submit_enabled }
onclick={
ctx.link().callback(|_e| {
Msg::RequestCredentialUpdate
})
}
>
{ "Password and Authentication Settings" }
</button>
</p>
</div>
<hr/>
<div>
<p>
<CreateResetCode uat={ uat.clone() } enabled={ submit_enabled } />
</p>
</div>
<hr/>
if uat.ui_hints.contains(&UiHint::CredentialUpdate) {
<div>
<p>
<button type="button" class="btn btn-primary"
disabled={ !submit_enabled }
onclick={
ctx.link().callback(|_e| {
Msg::RequestCredentialUpdate
})
}
>
{ "Password and Authentication Settings" }
</button>
</p>
</div>
<hr/>
<div>
<p>
<CreateResetCode uat={ uat.clone() } enabled={ submit_enabled } />
</p>
</div>
<hr/>
}
if uat.ui_hints.contains(&UiHint::PosixAccount) {
<div>
<p>
@ -233,6 +243,7 @@ impl ProfileApp {
</p>
</div>
}
</>
}
}

View file

@ -55,6 +55,7 @@ zxcvbn = { workspace=true }
clap = { workspace = true, features = ["derive"] }
clap_complete = { workspace=true }
uuid = { workspace=true }
url = { workspace = true }
[target."cfg(target_os = \"windows\")".dependencies.webauthn-authenticator-rs]
workspace = true

View file

@ -5,6 +5,7 @@ use std::path::PathBuf;
use clap::{CommandFactory, Parser};
use clap_complete::{generate_to, Shell};
use url::Url;
use uuid::Uuid;
include!("src/opt/ssh_authorizedkeys.rs");

View file

@ -16,6 +16,7 @@ extern crate tracing;
use crate::common::OpType;
use std::path::PathBuf;
use url::Url;
use uuid::Uuid;
include!("../opt/kanidm.rs");

View file

@ -8,7 +8,9 @@ use kanidm_client::ClientError::Http as ClientErrorHttp;
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::{
CUExtPortal, CUIntentToken, CURegState, CUSessionToken, CUStatus, TotpSecret,
};
use kanidm_proto::v1::{CredentialDetail, CredentialDetailType};
use qrcode::render::unicode;
use qrcode::QrCode;
@ -934,29 +936,54 @@ fn display_status(status: CUStatus) {
let CUStatus {
spn,
displayname,
ext_cred_portal,
mfaregstate: _,
can_commit,
primary,
mfaregstate: _,
primary_can_edit,
passkeys,
passkeys_can_edit,
} = status;
println!("spn: {}", spn);
println!("Name: {}", displayname);
if let Some(cred_detail) = &primary {
println!("Primary Credential:");
print!("{}", cred_detail);
} else {
println!("Primary Credential:");
println!(" not set");
}
println!("Passkeys:");
if passkeys.is_empty() {
println!(" not set");
} else {
for pk in passkeys {
println!(" {} ({})", pk.tag, pk.uuid);
match ext_cred_portal {
CUExtPortal::None => {}
CUExtPortal::Hidden => {
println!("Externally Managed: Contact your admin to update your account details.");
}
}
CUExtPortal::Some(url) => {
println!(
"Externally Managed: Visit {} to update your account details.",
url.as_str()
);
}
};
println!("Primary Credential:");
if primary_can_edit {
if let Some(cred_detail) = &primary {
print!("{}", cred_detail);
} else {
println!(" not set");
}
} else {
println!(" unable to modify");
};
println!("Passkeys:");
if passkeys_can_edit {
if passkeys.is_empty() {
println!(" not set");
} else {
for pk in passkeys {
println!(" {} ({})", pk.tag, pk.uuid);
}
}
} else {
println!(" unable to modify");
};
// We may need to be able to display if there are dangling
// curegstates, but the cli ui statemachine can match the

View file

@ -12,7 +12,8 @@ impl SynchOpt {
| SynchOpt::DestroyToken { copt, .. }
| SynchOpt::ForceRefresh { copt, .. }
| SynchOpt::Finalise { copt, .. }
| SynchOpt::Terminate { copt, .. } => copt.debug,
| SynchOpt::Terminate { copt, .. }
| SynchOpt::SetCredentialPortal { copt, .. } => copt.debug,
}
}
@ -33,6 +34,20 @@ impl SynchOpt {
Err(e) => error!("Error -> {:?}", e),
}
}
SynchOpt::SetCredentialPortal {
account_id,
copt,
url,
} => {
let client = copt.to_client(OpType::Write).await;
match client
.idm_sync_account_set_credential_portal(account_id, url.as_ref())
.await
{
Ok(()) => println!("Success"),
Err(e) => error!("Error -> {:?}", e),
}
}
SynchOpt::Create {
account_id,
copt,

View file

@ -802,6 +802,17 @@ pub enum SynchOpt {
#[clap(name = "get")]
/// Display a selected IDM sync account
Get(Named),
#[clap(name = "set-credential-portal")]
/// Set the url to the external credential portal. This will be displayed to synced users
/// so that they can be redirected to update their credentials on this portal.
SetCredentialPortal {
#[clap()]
account_id: String,
#[clap(flatten)]
copt: CommonOpt,
#[clap(name = "url")]
url: Option<Url>,
},
/// Create a new IDM sync account
#[clap(name = "create")]
Create {