mirror of
https://github.com/kanidm/kanidm.git
synced 2025-05-22 17:03:55 +02:00
Add client UX for redirecting to an external portal for synced accounts (#1791)
This commit is contained in:
parent
9d462b4b00
commit
17fa61ceeb
libs/client/src
proto/src
server
tools/cli
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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")),
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
Binary file not shown.
|
@ -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">
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue