diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4654df4bf..18d677b92 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -43,6 +43,7 @@ - Wei Jian Gan (weijiangan) - adamcstephens - Chris Olstrom (colstrom) +- Christopher-Robin (cebbinghaus) ## Acknowledgements diff --git a/libs/client/src/group.rs b/libs/client/src/group.rs index 68eef5791..a1d426fa4 100644 --- a/libs/client/src/group.rs +++ b/libs/client/src/group.rs @@ -109,6 +109,18 @@ impl KanidmClient { .await } + pub async fn group_account_policy_allow_primary_cred_fallback( + &self, + id: &str, + allow: bool, + ) -> Result<(), ClientError> { + self.perform_put_request( + &format!("/v1/group/{}/_attr/allow_primary_cred_fallback", id), + vec![allow.to_string()], + ) + .await + } + pub async fn idm_group_purge_mail(&self, id: &str) -> Result<(), ClientError> { self.idm_group_purge_attr(id, "mail").await } diff --git a/proto/src/attribute.rs b/proto/src/attribute.rs index 2cd022c22..282b97ced 100644 --- a/proto/src/attribute.rs +++ b/proto/src/attribute.rs @@ -172,6 +172,7 @@ pub enum Attribute { Uuid, Version, WebauthnAttestationCaList, + AllowPrimaryCredFallback, #[cfg(any(debug_assertions, test, feature = "test"))] NonExist, @@ -385,6 +386,7 @@ impl Attribute { Attribute::Uuid => ATTR_UUID, Attribute::Version => ATTR_VERSION, Attribute::WebauthnAttestationCaList => ATTR_WEBAUTHN_ATTESTATION_CA_LIST, + Attribute::AllowPrimaryCredFallback => ATTR_ALLOW_PRIMARY_CRED_FALLBACK, #[cfg(any(debug_assertions, test, feature = "test"))] Attribute::NonExist => TEST_ATTR_NON_EXIST, @@ -564,6 +566,7 @@ impl Attribute { ATTR_UUID => Attribute::Uuid, ATTR_VERSION => Attribute::Version, ATTR_WEBAUTHN_ATTESTATION_CA_LIST => Attribute::WebauthnAttestationCaList, + ATTR_ALLOW_PRIMARY_CRED_FALLBACK => Attribute::AllowPrimaryCredFallback, #[cfg(any(debug_assertions, test, feature = "test"))] TEST_ATTR_NON_EXIST => Attribute::NonExist, diff --git a/proto/src/constants.rs b/proto/src/constants.rs index 03ff5b67c..3c78a5dcc 100644 --- a/proto/src/constants.rs +++ b/proto/src/constants.rs @@ -212,6 +212,7 @@ pub const ATTR_USERPASSWORD: &str = "userpassword"; pub const ATTR_UUID: &str = "uuid"; pub const ATTR_VERSION: &str = "version"; pub const ATTR_WEBAUTHN_ATTESTATION_CA_LIST: &str = "webauthn_attestation_ca_list"; +pub const ATTR_ALLOW_PRIMARY_CRED_FALLBACK: &str = "allow_primary_cred_fallback"; pub const OAUTH2_SCOPE_EMAIL: &str = ATTR_EMAIL; pub const OAUTH2_SCOPE_GROUPS: &str = "groups"; diff --git a/server/lib/src/constants/acp.rs b/server/lib/src/constants/acp.rs index 65bac5daf..3e8185995 100644 --- a/server/lib/src/constants/acp.rs +++ b/server/lib/src/constants/acp.rs @@ -544,6 +544,62 @@ lazy_static! { }; } +lazy_static! { + pub static ref IDM_ACP_GROUP_ACCOUNT_POLICY_MANAGE_DL8: BuiltinAcp = BuiltinAcp { + classes: vec![ + EntryClass::Object, + EntryClass::AccessControlProfile, + EntryClass::AccessControlModify, + EntryClass::AccessControlSearch + ], + name: "idm_acp_group_account_policy_manage", + uuid: UUID_IDM_ACP_GROUP_ACCOUNT_POLICY_MANAGE, + description: "Builtin IDM Control for management of account policy on groups", + receiver: BuiltinAcpReceiver::Group(vec![UUID_IDM_ACCOUNT_POLICY_ADMINS]), + target: BuiltinAcpTarget::Filter(ProtoFilter::And(vec![ + match_class_filter!(EntryClass::Group), + FILTER_ANDNOT_TOMBSTONE_OR_RECYCLED.clone() + ])), + search_attrs: vec![ + Attribute::Class, + Attribute::Name, + Attribute::Uuid, + Attribute::AuthSessionExpiry, + Attribute::AuthPasswordMinimumLength, + Attribute::CredentialTypeMinimum, + Attribute::PrivilegeExpiry, + Attribute::WebauthnAttestationCaList, + Attribute::LimitSearchMaxResults, + Attribute::LimitSearchMaxFilterTest, + Attribute::AllowPrimaryCredFallback, + ], + modify_removed_attrs: vec![ + Attribute::Class, + Attribute::AuthSessionExpiry, + Attribute::AuthPasswordMinimumLength, + Attribute::CredentialTypeMinimum, + Attribute::PrivilegeExpiry, + Attribute::WebauthnAttestationCaList, + Attribute::LimitSearchMaxResults, + Attribute::LimitSearchMaxFilterTest, + Attribute::AllowPrimaryCredFallback, + ], + modify_present_attrs: vec![ + Attribute::Class, + Attribute::AuthSessionExpiry, + Attribute::AuthPasswordMinimumLength, + Attribute::CredentialTypeMinimum, + Attribute::PrivilegeExpiry, + Attribute::WebauthnAttestationCaList, + Attribute::LimitSearchMaxResults, + Attribute::LimitSearchMaxFilterTest, + Attribute::AllowPrimaryCredFallback, + ], + modify_classes: vec![EntryClass::AccountPolicy,], + ..Default::default() + }; +} + lazy_static! { pub static ref IDM_ACP_OAUTH2_MANAGE_DL4: BuiltinAcp = BuiltinAcp { classes: vec![ diff --git a/server/lib/src/constants/schema.rs b/server/lib/src/constants/schema.rs index d1564fac0..e7e4a8a01 100644 --- a/server/lib/src/constants/schema.rs +++ b/server/lib/src/constants/schema.rs @@ -779,6 +779,16 @@ pub static ref SCHEMA_ATTR_LINKED_GROUP_DL8: SchemaAttribute = SchemaAttribute { ..Default::default() }; +pub static ref SCHEMA_ATTR_ALLOW_PRIMARY_CRED_FALLBACK_DL8: SchemaAttribute = SchemaAttribute { + uuid: UUID_SCHEMA_ATTR_ALLOW_PRIMARY_CRED_FALLBACK, + name: Attribute::AllowPrimaryCredFallback, + description: "Allow fallback to primary password if no POSIX password exists".to_string(), + + multivalue: false, + syntax: SyntaxType::Boolean, + ..Default::default() +}; + pub static ref SCHEMA_ATTR_CERTIFICATE_DL7: SchemaAttribute = SchemaAttribute { uuid: UUID_SCHEMA_ATTR_CERTIFICATE, name: Attribute::Certificate, @@ -933,6 +943,25 @@ pub static ref SCHEMA_CLASS_ACCOUNT_POLICY_DL6: SchemaClass = SchemaClass { ..Default::default() }; +pub static ref SCHEMA_CLASS_ACCOUNT_POLICY_DL8: SchemaClass = SchemaClass { + uuid: UUID_SCHEMA_CLASS_ACCOUNT_POLICY, + name: EntryClass::AccountPolicy.into(), + description: "Policies applied to accounts that are members of a group".to_string(), + + systemmay: vec![ + Attribute::AuthSessionExpiry, + Attribute::PrivilegeExpiry, + Attribute::AuthPasswordMinimumLength, + Attribute::CredentialTypeMinimum, + Attribute::WebauthnAttestationCaList, + Attribute::LimitSearchMaxResults, + Attribute::LimitSearchMaxFilterTest, + Attribute::AllowPrimaryCredFallback, + ], + systemsupplements: vec![Attribute::Group.into()], + ..Default::default() +}; + pub static ref SCHEMA_CLASS_ACCOUNT: SchemaClass = SchemaClass { uuid: UUID_SCHEMA_CLASS_ACCOUNT, name: EntryClass::Account.into(), diff --git a/server/lib/src/constants/uuids.rs b/server/lib/src/constants/uuids.rs index 7dc456ece..5fa0a7b60 100644 --- a/server/lib/src/constants/uuids.rs +++ b/server/lib/src/constants/uuids.rs @@ -319,6 +319,8 @@ pub const UUID_SCHEMA_ATTR_LINKED_GROUP: Uuid = uuid!("00000000-0000-0000-0000-f pub const UUID_SCHEMA_ATTR_APPLICATION_PASSWORD: Uuid = uuid!("00000000-0000-0000-0000-ffff00000183"); pub const UUID_SCHEMA_ATTR_CREATED_AT_CID: Uuid = uuid!("00000000-0000-0000-0000-ffff00000184"); +pub const UUID_SCHEMA_ATTR_ALLOW_PRIMARY_CRED_FALLBACK: Uuid = + uuid!("00000000-0000-0000-0000-ffff00000185"); // System and domain infos // I'd like to strongly criticise william of the past for making poor choices about these allocations. diff --git a/server/lib/src/idm/account.rs b/server/lib/src/idm/account.rs index 9d1d3f7dc..5fd1d59f4 100644 --- a/server/lib/src/idm/account.rs +++ b/server/lib/src/idm/account.rs @@ -4,7 +4,7 @@ use std::time::Duration; use kanidm_proto::internal::{ BackupCodesView, CredentialStatus, UatPurpose, UiHint, UserAuthToken, }; -use kanidm_proto::v1::{UatStatus, UatStatusState}; +use kanidm_proto::v1::{UatStatus, UatStatusState, UnixGroupToken, UnixUserToken}; use time::OffsetDateTime; use uuid::Uuid; use webauthn_rs::prelude::{ @@ -12,13 +12,16 @@ use webauthn_rs::prelude::{ }; use super::accountpolicy::ResolvedAccountPolicy; +use super::group::{ + load_all_groups_from_account_entry, load_all_groups_from_account_entry_reduced, + load_all_groups_from_account_entry_with_policy, Group, UnixGroup, +}; use crate::constants::UUID_ANONYMOUS; use crate::credential::softlock::CredSoftLockPolicy; use crate::credential::{apppwd::ApplicationPassword, Credential}; use crate::entry::{Entry, EntryCommitted, EntryReduced, EntrySealed}; use crate::event::SearchEvent; use crate::idm::application::Application; -use crate::idm::group::Group; use crate::idm::ldap::{LdapBoundToken, LdapSession}; use crate::idm::server::{IdmServerProxyReadTransaction, IdmServerProxyWriteTransaction}; use crate::modify::{ModifyInvalid, ModifyList}; @@ -31,9 +34,10 @@ use sshkey_attest::proto::PublicKey as SshPublicKey; #[derive(Debug, Clone)] pub struct UnixExtensions { ucred: Option, - _shell: Option, + shell: Option, sshkeys: BTreeMap, - _gidnumber: u32, + gidnumber: u32, + groups: Vec, } impl UnixExtensions { @@ -71,7 +75,7 @@ pub struct Account { } macro_rules! try_from_entry { - ($value:expr, $groups:expr) => {{ + ($value:expr, $groups:expr, $unix_groups:expr) => {{ // Check the classes if !$value.attribute_equality(Attribute::Class, &EntryClass::Account.to_partialvalue()) { return Err(OperationError::InvalidAccountState(format!( @@ -177,11 +181,11 @@ macro_rules! try_from_entry { .get_ava_single_credential(Attribute::UnixPassword) .cloned(); - let _shell = $value + let shell = $value .get_ava_single_iutf8(Attribute::LoginShell) .map(|s| s.to_string()); - let _gidnumber = $value + let gidnumber = $value .get_ava_single_uint32(Attribute::GidNumber) .ok_or_else(|| { OperationError::InvalidAccountState(format!( @@ -190,11 +194,14 @@ macro_rules! try_from_entry { )) })?; + let groups = $unix_groups; + Some(UnixExtensions { ucred, - _shell, + shell, sshkeys, - _gidnumber, + gidnumber, + groups, }) } else { None @@ -233,13 +240,18 @@ impl Account { self.unix_extn.as_ref() } + pub(crate) fn primary(&self) -> Option<&Credential> { + self.primary.as_ref() + } + #[instrument(level = "trace", skip_all)] pub(crate) fn try_from_entry_ro( value: &Entry, qs: &mut QueryServerReadTransaction, ) -> Result { - let groups = Group::try_from_account_entry(value, qs)?; - try_from_entry!(value, groups) + let (groups, unix_groups) = load_all_groups_from_account_entry(value, qs)?; + + try_from_entry!(value, groups, unix_groups) } #[instrument(level = "trace", skip_all)] @@ -250,8 +262,10 @@ impl Account { where TXN: QueryServerTransaction<'a>, { - let (groups, rap) = Group::try_from_account_entry_with_policy(value, qs)?; - try_from_entry!(value, groups).map(|acct| (acct, rap)) + let ((groups, unix_groups), rap) = + load_all_groups_from_account_entry_with_policy(value, qs)?; + + try_from_entry!(value, groups, unix_groups).map(|acct| (acct, rap)) } #[instrument(level = "trace", skip_all)] @@ -259,8 +273,9 @@ impl Account { value: &Entry, qs: &mut QueryServerWriteTransaction, ) -> Result { - let groups = Group::try_from_account_entry(value, qs)?; - try_from_entry!(value, groups) + let (groups, unix_groups) = load_all_groups_from_account_entry(value, qs)?; + + try_from_entry!(value, groups, unix_groups) } #[instrument(level = "trace", skip_all)] @@ -268,8 +283,8 @@ impl Account { value: &Entry, qs: &mut QueryServerReadTransaction, ) -> Result { - let groups = Group::try_from_account_entry_reduced(value, qs)?; - try_from_entry!(value, groups) + let (groups, unix_groups) = load_all_groups_from_account_entry_reduced(value, qs)?; + try_from_entry!(value, groups, unix_groups) } /// Given the session_id and other metadata, create a user authentication token @@ -794,6 +809,35 @@ impl Account { let vap = Value::ApplicationPassword(ap); Ok(ModifyList::new_append(Attribute::ApplicationPassword, vap)) } + + pub(crate) fn to_unixusertoken(&self, ct: Duration) -> Result { + let (gidnumber, shell, sshkeys, groups) = match &self.unix_extn { + Some(ue) => { + let sshkeys: Vec = ue.sshkeys.keys().cloned().collect(); + (ue.gidnumber, ue.shell.clone(), sshkeys, ue.groups.clone()) + } + None => { + return Err(OperationError::InvalidAccountState(format!( + "Missing class: {}", + EntryClass::PosixAccount + ))); + } + }; + + let groups: Vec = groups.iter().map(|g| g.to_unixgrouptoken()).collect(); + + Ok(UnixUserToken { + name: self.name.clone(), + spn: self.spn.clone(), + displayname: self.displayname.clone(), + gidnumber, + uuid: self.uuid, + shell: shell.clone(), + groups, + sshkeys, + valid: self.is_within_valid_time(ct), + }) + } } // Need to also add a "to UserAuthToken" ... diff --git a/server/lib/src/idm/accountpolicy.rs b/server/lib/src/idm/accountpolicy.rs index 1378e53d5..417cacfb7 100644 --- a/server/lib/src/idm/accountpolicy.rs +++ b/server/lib/src/idm/accountpolicy.rs @@ -12,6 +12,7 @@ pub(crate) struct AccountPolicy { webauthn_att_ca_list: Option, limit_search_max_filter_test: Option, limit_search_max_results: Option, + allow_primary_cred_fallback: Option, } impl From<&EntrySealedCommitted> for Option { @@ -51,6 +52,9 @@ impl From<&EntrySealedCommitted> for Option { .get_ava_single_uint32(Attribute::LimitSearchMaxFilterTest) .map(|u| u as u64); + let allow_primary_cred_fallback = + val.get_ava_single_bool(Attribute::AllowPrimaryCredFallback); + Some(AccountPolicy { privilege_expiry, authsession_expiry, @@ -59,6 +63,7 @@ impl From<&EntrySealedCommitted> for Option { webauthn_att_ca_list, limit_search_max_filter_test, limit_search_max_results, + allow_primary_cred_fallback, }) } } @@ -73,6 +78,7 @@ pub(crate) struct ResolvedAccountPolicy { webauthn_att_ca_list: Option, limit_search_max_filter_test: Option, limit_search_max_results: Option, + allow_primary_cred_fallback: Option, } impl ResolvedAccountPolicy { @@ -86,6 +92,7 @@ impl ResolvedAccountPolicy { webauthn_att_ca_list: None, limit_search_max_filter_test: Some(DEFAULT_LIMIT_SEARCH_MAX_FILTER_TEST), limit_search_max_results: Some(DEFAULT_LIMIT_SEARCH_MAX_RESULTS), + allow_primary_cred_fallback: None, } } @@ -102,6 +109,7 @@ impl ResolvedAccountPolicy { webauthn_att_ca_list: None, limit_search_max_filter_test: None, limit_search_max_results: None, + allow_primary_cred_fallback: None, }; iter.for_each(|acc_pol| { @@ -152,6 +160,14 @@ impl ResolvedAccountPolicy { accumulate.webauthn_att_ca_list = Some(acc_pol_w_att_ca); } } + + if let Some(allow_primary_cred_fallback) = acc_pol.allow_primary_cred_fallback { + accumulate.allow_primary_cred_fallback = + match accumulate.allow_primary_cred_fallback { + Some(acc_fallback) => Some(allow_primary_cred_fallback && acc_fallback), + None => Some(allow_primary_cred_fallback), + }; + } }); accumulate @@ -184,6 +200,10 @@ impl ResolvedAccountPolicy { pub(crate) fn limit_search_max_filter_test(&self) -> Option { self.limit_search_max_filter_test } + + pub(crate) fn allow_primary_cred_fallback(&self) -> Option { + self.allow_primary_cred_fallback + } } #[cfg(test)] @@ -264,6 +284,7 @@ jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B webauthn_att_ca_list: Some(att_ca_list_a), limit_search_max_filter_test: Some(10), limit_search_max_results: Some(10), + allow_primary_cred_fallback: None, }; let mut att_ca_builder = AttestationCaListBuilder::new(); @@ -285,6 +306,7 @@ jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B webauthn_att_ca_list: Some(att_ca_list_b), limit_search_max_filter_test: Some(5), limit_search_max_results: Some(15), + allow_primary_cred_fallback: Some(false), }; let rap = ResolvedAccountPolicy::fold_from([policy_a, policy_b].into_iter()); @@ -295,6 +317,7 @@ jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B assert_eq!(rap.credential_policy, CredentialType::Passkey); assert_eq!(rap.limit_search_max_results(), Some(15)); assert_eq!(rap.limit_search_max_filter_test(), Some(10)); + assert_eq!(rap.allow_primary_cred_fallback(), Some(false)); let mut att_ca_builder = AttestationCaListBuilder::new(); diff --git a/server/lib/src/idm/group.rs b/server/lib/src/idm/group.rs index 0a2149dce..da90bbd7e 100644 --- a/server/lib/src/idm/group.rs +++ b/server/lib/src/idm/group.rs @@ -1,6 +1,8 @@ use std::collections::BTreeSet; +use std::iter; use kanidm_proto::internal::{Group as ProtoGroup, UiHint}; +use kanidm_proto::v1::UnixGroupToken; use uuid::Uuid; use super::accountpolicy::{AccountPolicy, ResolvedAccountPolicy}; @@ -8,14 +10,6 @@ use crate::entry::{Entry, EntryCommitted, EntryReduced, EntrySealed}; use crate::prelude::*; use crate::value::PartialValue; -#[derive(Debug, Clone)] -pub struct Group { - spn: String, - uuid: Uuid, - // We'll probably add policy and claims later to this - pub ui_hints: BTreeSet, -} - macro_rules! entry_groups { ($value:expr, $qs:expr) => {{ match $value.get_ava_as_refuuid(Attribute::MemberOf) { @@ -40,6 +34,119 @@ macro_rules! entry_groups { }}; } +macro_rules! load_all_groups_from_iter { + ($value:expr, $group_iter:expr) => {{ + let mut groups: Vec = vec![]; + let mut unix_groups: Vec = vec![]; + + let is_unix_account = $value.attribute_equality( + Attribute::Class, + &EntryClass::PosixAccount.to_partialvalue(), + ); + + // Setup the user private group + let spn = $value.get_ava_single_proto_string(Attribute::Spn).ok_or( + OperationError::InvalidAccountState(format!("Missing attribute: {}", Attribute::Spn)), + )?; + + let uuid = $value.get_uuid(); + + // We could allow ui hints on the user direct in the future? + let ui_hints = BTreeSet::default(); + + groups.push(Group { + spn: spn.clone(), + uuid: uuid.clone(), + ui_hints, + }); + + if is_unix_account { + let name = $value.get_ava_single_proto_string(Attribute::Name).ok_or( + OperationError::InvalidAccountState(format!( + "Missing attribute: {}", + Attribute::Name + )), + )?; + + let gidnumber = $value.get_ava_single_uint32(Attribute::GidNumber).ok_or( + OperationError::InvalidAccountState(format!( + "Missing attribute: {}", + Attribute::GidNumber + )), + )?; + + unix_groups.push(UnixGroup { + name, + spn, + gidnumber, + uuid, + }); + } + + for group_entry in $group_iter { + let group = Group::try_from_entry(group_entry.as_ref())?; + groups.push(group); + + if is_unix_account + && group_entry + .attribute_equality(Attribute::Class, &EntryClass::PosixGroup.to_partialvalue()) + { + let unix_group = UnixGroup::try_from_entry(group_entry.as_ref())?; + unix_groups.push(unix_group); + } + } + + (groups, unix_groups) + }}; +} + +pub(crate) fn load_all_groups_from_account_entry<'a, T>( + value: &Entry, + qs: &mut T, +) -> Result<(Vec, Vec), OperationError> +where + T: QueryServerTransaction<'a>, +{ + let group_iter = entry_groups!(value, qs); + Ok(load_all_groups_from_iter!(value, group_iter)) +} + +pub(crate) fn load_all_groups_from_account_entry_with_policy<'a, T>( + value: &Entry, + qs: &mut T, +) -> Result<((Vec, Vec), ResolvedAccountPolicy), OperationError> +where + T: QueryServerTransaction<'a>, +{ + let group_iter = entry_groups!(value, qs); + + let rap = ResolvedAccountPolicy::fold_from(group_iter.iter().filter_map(|entry| { + let acc_pol: Option = entry.as_ref().into(); + acc_pol + })); + + Ok((load_all_groups_from_iter!(value, group_iter), rap)) +} + +pub(crate) fn load_all_groups_from_account_entry_reduced<'a, T>( + value: &Entry, + qs: &mut T, +) -> Result<(Vec, Vec), OperationError> +where + T: QueryServerTransaction<'a>, +{ + let group_iter = entry_groups!(value, qs); + Ok(load_all_groups_from_iter!(value, group_iter)) +} + +#[derive(Debug, Clone)] +pub struct Group { + spn: String, + uuid: Uuid, + // We'll probably add policy and claims later to this + pub ui_hints: BTreeSet, +} + macro_rules! upg_from_account_e { ($value:expr, $groups:expr) => {{ // Setup the user private group @@ -99,26 +206,6 @@ impl Group { upg_from_account_e!(value, groups) } - pub(crate) fn try_from_account_entry_with_policy<'b, 'a, TXN>( - value: &'b Entry, - qs: &mut TXN, - ) -> Result<(Vec, ResolvedAccountPolicy), OperationError> - where - TXN: QueryServerTransaction<'a>, - { - let groups = entry_groups!(value, qs); - // Get the account policy here. - - let rap = ResolvedAccountPolicy::fold_from(groups.iter().filter_map(|entry| { - let acc_pol: Option = entry.as_ref().into(); - acc_pol - })); - - let r_groups = upg_from_account_e!(value, groups)?; - - Ok((r_groups, rap)) - } - pub fn try_from_entry( value: &Entry, ) -> Result { @@ -164,3 +251,195 @@ impl Group { } } } + +#[derive(Debug, Clone)] +pub(crate) struct UnixGroup { + pub name: String, + pub spn: String, + pub gidnumber: u32, + pub uuid: Uuid, +} + +macro_rules! try_from_group_e { + ($value:expr) => {{ + // We could be looking at a user for their UPG, OR a true group. + + if !(($value.attribute_equality(Attribute::Class, &EntryClass::Account.to_partialvalue()) + && $value.attribute_equality( + Attribute::Class, + &EntryClass::PosixAccount.to_partialvalue(), + )) + || ($value.attribute_equality(Attribute::Class, &EntryClass::Group.to_partialvalue()) + && $value.attribute_equality( + Attribute::Class, + &EntryClass::PosixGroup.to_partialvalue(), + ))) + { + return Err(OperationError::InvalidAccountState(format!( + "Missing {}: {} && {} OR {} && {}", + Attribute::Class, + Attribute::Account, + EntryClass::PosixAccount, + Attribute::Group, + EntryClass::PosixGroup, + ))); + } + + let name = $value + .get_ava_single_iname(Attribute::Name) + .map(|s| s.to_string()) + .ok_or_else(|| { + OperationError::InvalidAccountState(format!( + "Missing attribute: {}", + Attribute::Name + )) + })?; + + let spn = $value + .get_ava_single_proto_string(Attribute::Spn) + .ok_or_else(|| { + OperationError::InvalidAccountState(format!( + "Missing attribute: {}", + Attribute::Spn + )) + })?; + + let uuid = $value.get_uuid(); + + let gidnumber = $value + .get_ava_single_uint32(Attribute::GidNumber) + .ok_or_else(|| { + OperationError::InvalidAccountState(format!( + "Missing attribute: {}", + Attribute::GidNumber + )) + })?; + + Ok(UnixGroup { + name, + spn, + gidnumber, + uuid, + }) + }}; +} + +macro_rules! try_from_account_group_e { + ($value:expr, $qs:expr) => {{ + // First synthesise the self-group from the account. + // We have already checked these, but paranoia is better than + // complacency. + if !$value.attribute_equality(Attribute::Class, &EntryClass::Account.to_partialvalue()) { + return Err(OperationError::InvalidAccountState(format!( + "Missing class: {}", + EntryClass::Account + ))); + } + + if !$value.attribute_equality( + Attribute::Class, + &EntryClass::PosixAccount.to_partialvalue(), + ) { + return Err(OperationError::InvalidAccountState(format!( + "Missing class: {}", + EntryClass::PosixAccount + ))); + } + + let name = $value + .get_ava_single_iname(Attribute::Name) + .map(|s| s.to_string()) + .ok_or_else(|| { + OperationError::InvalidAccountState(format!( + "Missing attribute: {}", + Attribute::Name + )) + })?; + + let spn = $value + .get_ava_single_proto_string(Attribute::Spn) + .ok_or_else(|| { + OperationError::InvalidAccountState(format!( + "Missing attribute: {}", + Attribute::Spn + )) + })?; + + let uuid = $value.get_uuid(); + + let gidnumber = $value + .get_ava_single_uint32(Attribute::GidNumber) + .ok_or_else(|| { + OperationError::InvalidAccountState(format!( + "Missing attribute: {}", + Attribute::GidNumber + )) + })?; + + // This is the user private group. + let upg = UnixGroup { + name, + spn, + gidnumber, + uuid, + }; + + match $value.get_ava_as_refuuid(Attribute::MemberOf) { + Some(riter) => { + let f = filter!(f_and!([ + f_eq(Attribute::Class, EntryClass::PosixGroup.into()), + f_eq(Attribute::Class, EntryClass::Group.into()), + f_or( + riter + .map(|u| f_eq(Attribute::Uuid, PartialValue::Uuid(u))) + .collect() + ) + ])); + let group_entries: Vec<_> = $qs.internal_search(f)?; + let groups: Result, _> = iter::once(Ok(upg)) + .chain( + group_entries + .iter() + .map(|e| UnixGroup::try_from_entry(e.as_ref())), + ) + .collect(); + groups + } + None => { + // No memberof, no groups! + Ok(vec![upg]) + } + } + }}; +} + +impl UnixGroup { + #[allow(dead_code)] + pub(crate) fn try_from_account_entry_rw( + value: &Entry, + qs: &mut QueryServerWriteTransaction, + ) -> Result, OperationError> { + try_from_account_group_e!(value, qs) + } + + pub(crate) fn try_from_entry_reduced( + value: &Entry, + ) -> Result { + try_from_group_e!(value) + } + + pub(crate) fn try_from_entry( + value: &Entry, + ) -> Result { + try_from_group_e!(value) + } + + pub(crate) fn to_unixgrouptoken(&self) -> UnixGroupToken { + UnixGroupToken { + name: self.name.clone(), + spn: self.spn.clone(), + uuid: self.uuid, + gidnumber: self.gidnumber, + } + } +} diff --git a/server/lib/src/idm/mod.rs b/server/lib/src/idm/mod.rs index 21c9c89b3..122d0936f 100644 --- a/server/lib/src/idm/mod.rs +++ b/server/lib/src/idm/mod.rs @@ -21,7 +21,6 @@ pub(crate) mod reauth; pub mod scim; pub mod server; pub mod serviceaccount; -pub(crate) mod unix; use crate::server::identity::Source; use compact_jwt::JwsCompact; diff --git a/server/lib/src/idm/server.rs b/server/lib/src/idm/server.rs index 7f9855564..86d619be1 100644 --- a/server/lib/src/idm/server.rs +++ b/server/lib/src/idm/server.rs @@ -46,6 +46,7 @@ use crate::idm::event::{ RegenerateRadiusSecretEvent, UnixGroupTokenEvent, UnixPasswordChangeEvent, UnixUserAuthEvent, UnixUserTokenEvent, }; +use crate::idm::group::UnixGroup; use crate::idm::oauth2::{ Oauth2ResourceServers, Oauth2ResourceServersReadTransaction, Oauth2ResourceServersWriteTransaction, @@ -53,7 +54,6 @@ use crate::idm::oauth2::{ use crate::idm::radius::RadiusAccount; use crate::idm::scim::SyncAccount; use crate::idm::serviceaccount::ServiceAccount; -use crate::idm::unix::{UnixGroup, UnixUserAccount}; use crate::idm::AuthState; use crate::prelude::*; use crate::server::keys::KeyProvidersTransaction; @@ -1275,113 +1275,116 @@ impl<'a> IdmServerAuthTransaction<'a> { } } + async fn auth_with_unix_pass( + &mut self, + id: Uuid, + cleartext: &str, + ct: Duration, + ) -> Result, OperationError> { + let entry = match self.qs_read.internal_search_uuid(id) { + Ok(entry) => entry, + Err(e) => { + admin_error!("Failed to start auth unix -> {:?}", e); + return Err(e); + } + }; + + let (account, acp) = + Account::try_from_entry_with_policy(entry.as_ref(), &mut self.qs_read)?; + + if !account.is_within_valid_time(ct) { + security_info!("Account is expired or not yet valid."); + return Ok(None); + } + + let cred = if acp.allow_primary_cred_fallback() == Some(true) { + account + .unix_extn() + .and_then(|extn| extn.ucred()) + .or_else(|| account.primary()) + } else { + account.unix_extn().and_then(|extn| extn.ucred()) + }; + + let (cred, cred_id, cred_slock_policy) = match cred { + None => { + if acp.allow_primary_cred_fallback() == Some(true) { + security_info!("Account does not have a POSIX or primary password configured."); + } else { + security_info!("Account does not have a POSIX password configured."); + } + return Ok(None); + } + Some(cred) => (cred, cred.uuid, cred.softlock_policy()), + }; + + // The credential should only ever be a password + let Ok(password) = cred.password_ref() else { + error!("User's UNIX or primary credential is not a password, can't authenticate!"); + return Err(OperationError::InvalidState); + }; + + let slock_ref = { + let softlock_read = self.softlocks.read(); + if let Some(slock_ref) = softlock_read.get(&cred_id) { + slock_ref.clone() + } else { + let _session_ticket = self.session_ticket.acquire().await; + let mut softlock_write = self.softlocks.write(); + let slock = Arc::new(Mutex::new(CredSoftLock::new(cred_slock_policy))); + softlock_write.insert(cred_id, slock.clone()); + softlock_write.commit(); + slock + } + }; + + let mut slock = slock_ref.lock().await; + + slock.apply_time_step(ct); + + if !slock.is_valid() { + security_info!("Account is softlocked."); + return Ok(None); + } + + // Check the provided password against the stored hash + let valid = password.verify(cleartext).map_err(|e| { + error!(crypto_err = ?e); + e.into() + })?; + + if !valid { + // Update it. + slock.record_failure(ct); + + return Ok(None); + } + + security_info!("Successfully authenticated with unix (or primary) password"); + if password.requires_upgrade() { + self.async_tx + .send(DelayedAction::UnixPwUpgrade(UnixPasswordUpgrade { + target_uuid: id, + existing_password: cleartext.to_string(), + })) + .map_err(|_| { + admin_error!("failed to queue delayed action - unix password upgrade"); + OperationError::InvalidState + })?; + } + + Ok(Some(account)) + } + pub async fn auth_unix( &mut self, uae: &UnixUserAuthEvent, ct: Duration, ) -> Result, OperationError> { - // Get the entry/target we are working on. - let account = self - .qs_read - .internal_search_uuid(uae.target) - .and_then(|account_entry| { - UnixUserAccount::try_from_entry_ro(account_entry.as_ref(), &mut self.qs_read) - }) - .map_err(|e| { - admin_error!("Failed to start auth unix -> {:?}", e); - e - })?; - - if !account.is_within_valid_time(ct) { - security_info!("Account is not within valid time period"); - return Ok(None); - } - - let maybe_slock_ref = match account.unix_cred_uuid_and_policy() { - Some((cred_uuid, policy)) => { - let softlock_read = self.softlocks.read(); - let slock_ref = match softlock_read.get(&cred_uuid) { - Some(slock_ref) => slock_ref.clone(), - None => { - let _session_ticket = self.session_ticket.acquire().await; - let mut softlock_write = self.softlocks.write(); - let slock = Arc::new(Mutex::new(CredSoftLock::new(policy))); - softlock_write.insert(cred_uuid, slock.clone()); - softlock_write.commit(); - slock - } - }; - Some(slock_ref) - } - None => None, - }; - - let maybe_slock = if let Some(s) = maybe_slock_ref.as_ref() { - Some(s.lock().await) - } else { - None - }; - - let maybe_valid = if let Some(mut slock) = maybe_slock { - // Apply the current time. - slock.apply_time_step(ct); - // Now check the results - if slock.is_valid() { - Some(slock) - } else { - None - } - } else { - None - }; - - // Validate the unix_pw - this checks the account/cred lock states. - let res = if let Some(mut slock) = maybe_valid { - // Account is unlocked, can proceed. - account - .verify_unix_credential(uae.cleartext.as_str(), &self.async_tx, ct) - .inspect(|res| { - if res.is_none() { - // Update it. - slock.record_failure(ct); - }; - }) - } else { - // Account is slocked! - security_info!("Account is softlocked."); - Ok(None) - }; - res - } - - pub async fn token_auth_ldap( - &mut self, - lae: &LdapTokenAuthEvent, - ct: Duration, - ) -> Result, OperationError> { - match self.validate_and_parse_token_to_token(&lae.token, ct)? { - Token::UserAuthToken(uat) => { - let spn = uat.spn.clone(); - Ok(Some(LdapBoundToken { - session_id: uat.session_id, - spn, - effective_session: LdapSession::UserAuthToken(uat), - })) - } - Token::ApiToken(apit, entry) => { - let spn = entry - .get_ava_single_proto_string(Attribute::Spn) - .ok_or_else(|| { - OperationError::InvalidAccountState("Missing attribute: spn".to_string()) - })?; - - Ok(Some(LdapBoundToken { - session_id: apit.token_id, - spn, - effective_session: LdapSession::ApiToken(apit), - })) - } - } + Ok(self + .auth_with_unix_pass(uae.target, &uae.cleartext, ct) + .await? + .and_then(|acc| acc.to_unixusertoken(ct).ok())) } pub async fn auth_ldap( @@ -1389,14 +1392,14 @@ impl<'a> IdmServerAuthTransaction<'a> { lae: &LdapAuthEvent, ct: Duration, ) -> Result, OperationError> { - let account_entry = self.qs_read.internal_search_uuid(lae.target).map_err(|e| { - admin_error!("Failed to start auth ldap -> {:?}", e); - e - })?; - - // if anonymous if lae.target == UUID_ANONYMOUS { + let account_entry = self.qs_read.internal_search_uuid(lae.target).map_err(|e| { + admin_error!("Failed to start auth ldap -> {:?}", e); + e + })?; + let account = Account::try_from_entry_ro(account_entry.as_ref(), &mut self.qs_read)?; + // Check if the anon account has been locked. if !account.is_within_valid_time(ct) { security_info!("Account is not within valid time period"); @@ -1422,85 +1425,61 @@ impl<'a> IdmServerAuthTransaction<'a> { security_info!("Bind not allowed through Unix passwords."); return Ok(None); } - let account = - UnixUserAccount::try_from_entry_ro(account_entry.as_ref(), &mut self.qs_read)?; - if !account.is_within_valid_time(ct) { - security_info!("Account is not within valid time period"); - return Ok(None); + let auth = self + .auth_with_unix_pass(lae.target, &lae.cleartext, ct) + .await?; + + match auth { + Some(account) => { + let session_id = Uuid::new_v4(); + security_info!( + "Starting session {} for {} {}", + session_id, + account.spn, + account.uuid + ); + + Ok(Some(LdapBoundToken { + spn: account.spn, + session_id, + effective_session: LdapSession::UnixBind(account.uuid), + })) + } + None => Ok(None), } + } + } - let maybe_slock_ref = match account.unix_cred_uuid_and_policy() { - Some((cred_uuid, policy)) => { - let softlock_read = self.softlocks.read(); - let slock_ref = match softlock_read.get(&cred_uuid) { - Some(slock_ref) => slock_ref.clone(), - None => { - let _session_ticket = self.session_ticket.acquire().await; - let mut softlock_write = self.softlocks.write(); - let slock = Arc::new(Mutex::new(CredSoftLock::new(policy))); - softlock_write.insert(cred_uuid, slock.clone()); - softlock_write.commit(); - slock - } - }; - Ok(slock_ref) - } - None => Err(false), - }; + pub async fn token_auth_ldap( + &mut self, + lae: &LdapTokenAuthEvent, + ct: Duration, + ) -> Result, OperationError> { + match self.validate_and_parse_token_to_token(&lae.token, ct)? { + Token::UserAuthToken(uat) => { + let spn = uat.spn.clone(); + Ok(Some(LdapBoundToken { + session_id: uat.session_id, + spn, + effective_session: LdapSession::UserAuthToken(uat), + })) + } + Token::ApiToken(apit, entry) => { + let spn = entry + .get_ava_single_proto_string(Attribute::Spn) + .ok_or_else(|| { + OperationError::InvalidAccountState(format!( + "Missing attribute: {}", + Attribute::Spn + )) + })?; - let maybe_slock = match maybe_slock_ref.as_ref() { - Ok(s) => Ok(s.lock().await), - Err(cred_state) => Err(cred_state), - }; - - let maybe_valid = match maybe_slock { - Ok(mut slock) => { - // Apply the current time. - slock.apply_time_step(ct); - // Now check the results - if slock.is_valid() { - Ok(slock) - } else { - Err(true) - } - } - Err(cred_state) => Err(*cred_state), - }; - - match maybe_valid { - Ok(mut slock) => { - if account - .verify_unix_credential(lae.cleartext.as_str(), &self.async_tx, ct)? - .is_some() - { - let session_id = Uuid::new_v4(); - security_info!( - "Starting session {} for {} {}", - session_id, - account.spn, - account.uuid - ); - - Ok(Some(LdapBoundToken { - spn: account.spn, - session_id, - effective_session: LdapSession::UnixBind(account.uuid), - })) - } else { - // PW failure, update softlock. - slock.record_failure(ct); - Ok(None) - } - } - Err(true) => { - security_info!("Account is softlocked."); - Ok(None) - } - Err(false) => { - security_info!("Account does not have a configured posix password."); - Ok(None) - } + Ok(Some(LdapBoundToken { + session_id: apit.token_id, + spn, + effective_session: LdapSession::ApiToken(apit), + })) } } } @@ -1518,6 +1497,35 @@ impl<'a> IdmServerTransaction<'a> for IdmServerProxyReadTransaction<'a> { } } +fn gen_password_mod( + cleartext: &str, + crypto_policy: &CryptoPolicy, +) -> Result, OperationError> { + let new_cred = Credential::new_password_only(crypto_policy, cleartext)?; + let cred_value = Value::new_credential("unix", new_cred); + Ok(ModifyList::new_purge_and_set( + Attribute::UnixPassword, + cred_value, + )) +} + +fn gen_password_upgrade_mod( + unix_cred: &Credential, + cleartext: &str, + crypto_policy: &CryptoPolicy, +) -> Result>, OperationError> { + if let Some(new_cred) = unix_cred.upgrade_password(crypto_policy, cleartext)? { + let cred_value = Value::new_credential("primary", new_cred); + Ok(Some(ModifyList::new_purge_and_set( + Attribute::UnixPassword, + cred_value, + ))) + } else { + // No action, not the same pw + Ok(None) + } +} + impl<'a> IdmServerProxyReadTransaction<'a> { pub fn jws_public_jwk(&mut self, key_id: &str) -> Result { self.qs_read @@ -1556,9 +1564,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> { let account = self .qs_read .impersonate_search_uuid(uute.target, &uute.ident) - .and_then(|account_entry| { - UnixUserAccount::try_from_entry_ro(&account_entry, &mut self.qs_read) - }) + .and_then(|account_entry| Account::try_from_entry_ro(&account_entry, &mut self.qs_read)) .map_err(|e| { admin_error!("Failed to start unix user token -> {:?}", e); e @@ -1579,7 +1585,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> { admin_error!("Failed to start unix group token {:?}", e); e })?; - group.to_unixgrouptoken() + Ok(group.to_unixgrouptoken()) } pub fn get_credentialstatus( @@ -1786,13 +1792,20 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { .internal_search_uuid(pce.target) .and_then(|account_entry| { // Assert the account is unix and valid. - UnixUserAccount::try_from_entry_rw(&account_entry, &mut self.qs_write) + Account::try_from_entry_rw(&account_entry, &mut self.qs_write) }) .map_err(|e| { admin_error!("Failed to start set unix account password {:?}", e); e })?; - // Ask if tis all good - this step checks pwpolicy and such + + // Account is not a unix account + if account.unix_extn().is_none() { + return Err(OperationError::InvalidAccountState(format!( + "Missing class: {}", + EntryClass::PosixAccount + ))); + } // Deny the change if the account is anonymous! if account.is_anonymous() { @@ -1800,9 +1813,8 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { return Err(OperationError::SystemProtectedObject); } - let modlist = account - .gen_password_mod(pce.cleartext.as_str(), self.crypto_policy) - .map_err(|e| { + let modlist = + gen_password_mod(pce.cleartext.as_str(), self.crypto_policy).map_err(|e| { admin_error!(?e, "Unable to generate password change modlist"); e })?; @@ -1969,24 +1981,37 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { .qs_write .internal_search_uuid(pwu.target_uuid) .and_then(|account_entry| { - UnixUserAccount::try_from_entry_rw(&account_entry, &mut self.qs_write) + Account::try_from_entry_rw(&account_entry, &mut self.qs_write) }) .map_err(|e| { admin_error!("Failed to start unix pw upgrade -> {:?}", e); e })?; - let maybe_modlist = - account.gen_password_upgrade_mod(pwu.existing_password.as_str(), self.crypto_policy)?; + let cred = match account.unix_extn() { + Some(ue) => ue.ucred(), + None => { + return Err(OperationError::InvalidAccountState(format!( + "Missing class: {}", + EntryClass::PosixAccount + ))); + } + }; - if let Some(modlist) = maybe_modlist { - self.qs_write.internal_modify( + // No credential no problem + let Some(cred) = cred else { + return Ok(()); + }; + + let maybe_modlist = + gen_password_upgrade_mod(cred, pwu.existing_password.as_str(), self.crypto_policy)?; + + match maybe_modlist { + Some(modlist) => self.qs_write.internal_modify( &filter_all!(f_eq(Attribute::Uuid, PartialValue::Uuid(pwu.target_uuid))), &modlist, - ) - } else { - // No action needed, it's probably been changed/updated already. - Ok(()) + ), + None => Ok(()), } } @@ -2188,9 +2213,10 @@ mod tests { use crate::idm::delayed::{AuthSessionRecord, DelayedAction}; use crate::idm::event::{AuthEvent, AuthResult}; use crate::idm::event::{ - PasswordChangeEvent, RadiusAuthTokenEvent, RegenerateRadiusSecretEvent, + LdapAuthEvent, PasswordChangeEvent, RadiusAuthTokenEvent, RegenerateRadiusSecretEvent, UnixGroupTokenEvent, UnixPasswordChangeEvent, UnixUserAuthEvent, UnixUserTokenEvent, }; + use crate::idm::server::{IdmServer, IdmServerTransaction, Token}; use crate::idm::AuthState; use crate::modify::{Modify, ModifyList}; @@ -4125,4 +4151,132 @@ mod tests { // Any checks? } + + async fn idm_fallback_auth_fixture( + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + has_posix_password: bool, + allow_primary_cred_fallback: Option, + expected: Option<()>, + ) { + let ct = Duration::from_secs(TEST_CURRENT_TIME); + let target_uuid = Uuid::new_v4(); + let p = CryptoPolicy::minimum(); + + { + let mut idms_prox_write = idms.proxy_write(ct).await.unwrap(); + + if let Some(allow_primary_cred_fallback) = allow_primary_cred_fallback { + idms_prox_write + .qs_write + .internal_modify_uuid( + UUID_IDM_ALL_ACCOUNTS, + &ModifyList::new_purge_and_set( + Attribute::AllowPrimaryCredFallback, + Value::new_bool(allow_primary_cred_fallback), + ), + ) + .expect("Unable to change default session exp"); + } + + let mut e = entry_init!( + (Attribute::Class, EntryClass::Object.to_value()), + (Attribute::Class, EntryClass::Account.to_value()), + (Attribute::Class, EntryClass::Person.to_value()), + (Attribute::Uuid, Value::Uuid(target_uuid)), + (Attribute::Name, Value::new_iname("kevin")), + (Attribute::DisplayName, Value::new_utf8s("Kevin")), + (Attribute::Class, EntryClass::PosixAccount.to_value()), + ( + Attribute::PrimaryCredential, + Value::Cred( + "primary".to_string(), + Credential::new_password_only(&p, "banana").unwrap() + ) + ) + ); + + if has_posix_password { + e.add_ava( + Attribute::UnixPassword, + Value::Cred( + "unix".to_string(), + Credential::new_password_only(&p, "kampai").unwrap(), + ), + ); + } + + let ce = CreateEvent::new_internal(vec![e]); + let cr = idms_prox_write.qs_write.create(&ce); + assert!(cr.is_ok()); + idms_prox_write.commit().expect("Must not fail"); + } + + let result = idms + .auth() + .await + .unwrap() + .auth_ldap( + &LdapAuthEvent { + target: target_uuid, + cleartext: if has_posix_password { + "kampai".to_string() + } else { + "banana".to_string() + }, + }, + ct, + ) + .await; + + assert!(result.is_ok()); + if let Some(_) = expected { + assert!(result.unwrap().is_some()); + } else { + assert!(result.unwrap().is_none()); + } + } + + #[idm_test] + async fn test_idm_fallback_auth_no_pass_none_fallback( + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + ) { + idm_fallback_auth_fixture(idms, _idms_delayed, false, None, None).await; + } + #[idm_test] + async fn test_idm_fallback_auth_pass_none_fallback( + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + ) { + idm_fallback_auth_fixture(idms, _idms_delayed, true, None, Some(())).await; + } + #[idm_test] + async fn test_idm_fallback_auth_no_pass_true_fallback( + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + ) { + idm_fallback_auth_fixture(idms, _idms_delayed, false, Some(true), Some(())).await; + } + #[idm_test] + async fn test_idm_fallback_auth_pass_true_fallback( + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + ) { + idm_fallback_auth_fixture(idms, _idms_delayed, true, Some(true), Some(())).await; + } + #[idm_test] + async fn test_idm_fallback_auth_no_pass_false_fallback( + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + ) { + idm_fallback_auth_fixture(idms, _idms_delayed, false, Some(false), None).await; + } + #[idm_test] + async fn test_idm_fallback_auth_pass_false_fallback( + idms: &IdmServer, + _idms_delayed: &mut IdmServerDelayed, + ) { + idm_fallback_auth_fixture(idms, _idms_delayed, true, Some(false), Some(())).await; + } } diff --git a/server/lib/src/idm/unix.rs b/server/lib/src/idm/unix.rs deleted file mode 100644 index 4c6e2e03c..000000000 --- a/server/lib/src/idm/unix.rs +++ /dev/null @@ -1,518 +0,0 @@ -use std::iter; -// use crossbeam::channel::Sender; -use std::time::Duration; - -use kanidm_proto::v1::{UnixGroupToken, UnixUserToken}; -use time::OffsetDateTime; -use tokio::sync::mpsc::UnboundedSender as Sender; -use uuid::Uuid; - -use kanidm_lib_crypto::CryptoPolicy; - -use crate::credential::softlock::CredSoftLockPolicy; -use crate::credential::Credential; -use crate::idm::delayed::{DelayedAction, UnixPasswordUpgrade}; -use crate::modify::{ModifyInvalid, ModifyList}; -use crate::prelude::*; - -#[derive(Debug, Clone)] -pub(crate) struct UnixUserAccount { - pub name: String, - pub spn: String, - pub displayname: String, - pub uuid: Uuid, - pub valid_from: Option, - pub expire: Option, - pub radius_secret: Option, - pub mail: Vec, - - cred: Option, - pub shell: Option, - pub sshkeys: Vec, - pub gidnumber: u32, - pub groups: Vec, -} - -macro_rules! try_from_entry { - ($value:expr, $groups:expr) => {{ - if !$value.attribute_equality(Attribute::Class, &EntryClass::Account.to_partialvalue()) { - return Err(OperationError::InvalidAccountState( - "Missing class: account".to_string(), - )); - } - - if !$value.attribute_equality( - Attribute::Class, - &EntryClass::PosixAccount.to_partialvalue(), - ) { - return Err(OperationError::InvalidAccountState( - "Missing class: posixaccount".to_string(), - )); - } - - let name = $value - .get_ava_single_iname(Attribute::Name) - .map(|s| s.to_string()) - .ok_or_else(|| { - OperationError::InvalidAccountState(format!( - "Missing attribute: {}", - Attribute::Name - )) - })?; - - let spn = $value - .get_ava_single_proto_string(Attribute::Spn) - .ok_or_else(|| { - OperationError::InvalidAccountState(format!( - "Missing attribute: {}", - Attribute::Spn - )) - })?; - - let uuid = $value.get_uuid(); - - let displayname = $value - .get_ava_single_utf8(Attribute::DisplayName) - .map(|s| s.to_string()) - .ok_or_else(|| { - OperationError::InvalidAccountState(format!( - "Missing attribute: {}", - Attribute::DisplayName - )) - })?; - - let gidnumber = $value - .get_ava_single_uint32(Attribute::GidNumber) - .ok_or_else(|| { - OperationError::InvalidAccountState(format!( - "Missing attribute: {}", - Attribute::GidNumber - )) - })?; - - let shell = $value - .get_ava_single_iutf8(Attribute::LoginShell) - .map(|s| s.to_string()); - - let sshkeys = $value - .get_ava_iter_sshpubkeys(Attribute::SshPublicKey) - .map(|i| i.map(|s| s.to_string()).collect()) - .unwrap_or_default(); - - let cred = $value - .get_ava_single_credential(Attribute::UnixPassword) - .cloned(); - - let radius_secret = $value - .get_ava_single_secret(Attribute::RadiusSecret) - .map(str::to_string); - - let mail = $value - .get_ava_iter_mail(Attribute::Mail) - .map(|i| i.map(str::to_string).collect()) - .unwrap_or_default(); - - let valid_from = $value.get_ava_single_datetime(Attribute::AccountValidFrom); - - let expire = $value.get_ava_single_datetime(Attribute::AccountExpire); - - Ok(UnixUserAccount { - name, - spn, - uuid, - displayname, - gidnumber, - shell, - sshkeys, - groups: $groups, - cred, - valid_from, - expire, - radius_secret, - mail, - }) - }}; -} - -impl UnixUserAccount { - pub(crate) fn try_from_entry_rw( - value: &Entry, - qs: &mut QueryServerWriteTransaction, - ) -> Result { - let groups = UnixGroup::try_from_account_entry_rw(value, qs)?; - try_from_entry!(value, groups) - } - - pub(crate) fn try_from_entry_ro( - value: &Entry, - qs: &mut QueryServerReadTransaction, - ) -> Result { - let groups = UnixGroup::try_from_account_entry_ro(value, qs)?; - try_from_entry!(value, groups) - } - - pub(crate) fn to_unixusertoken(&self, ct: Duration) -> Result { - let groups: Result, _> = self.groups.iter().map(|g| g.to_unixgrouptoken()).collect(); - let groups = groups?; - - Ok(UnixUserToken { - name: self.name.clone(), - spn: self.spn.clone(), - displayname: self.displayname.clone(), - gidnumber: self.gidnumber, - uuid: self.uuid, - shell: self.shell.clone(), - groups, - sshkeys: self.sshkeys.clone(), - valid: self.is_within_valid_time(ct), - }) - } - - pub fn unix_cred_uuid_and_policy(&self) -> Option<(Uuid, CredSoftLockPolicy)> { - self.cred - .as_ref() - .map(|cred| (cred.uuid, cred.softlock_policy())) - } - - pub fn is_anonymous(&self) -> bool { - self.uuid == UUID_ANONYMOUS - } - - pub(crate) fn gen_password_mod( - &self, - cleartext: &str, - crypto_policy: &CryptoPolicy, - ) -> Result, OperationError> { - let ncred = Credential::new_password_only(crypto_policy, cleartext)?; - let vcred = Value::new_credential("unix", ncred); - Ok(ModifyList::new_purge_and_set( - Attribute::UnixPassword, - vcred, - )) - } - - pub(crate) fn gen_password_upgrade_mod( - &self, - cleartext: &str, - crypto_policy: &CryptoPolicy, - ) -> Result>, OperationError> { - match &self.cred { - // Change the cred - Some(ucred) => { - if let Some(ncred) = ucred.upgrade_password(crypto_policy, cleartext)? { - let vcred = Value::new_credential("primary", ncred); - Ok(Some(ModifyList::new_purge_and_set( - Attribute::UnixPassword, - vcred, - ))) - } else { - // No action, not the same pw - Ok(None) - } - } - // Nothing to do. - None => Ok(None), - } - } - - pub fn is_within_valid_time(&self, ct: Duration) -> bool { - let cot = OffsetDateTime::UNIX_EPOCH + ct; - - let vmin = if let Some(vft) = &self.valid_from { - // If current time greater than start time window - vft < &cot - } else { - // We have no time, not expired. - true - }; - let vmax = if let Some(ext) = &self.expire { - // If exp greater than ct then expired. - &cot < ext - } else { - // If not present, we are not expired - true - }; - // Mix the results - vmin && vmax - } - - // Get related inputs, such as account name, email, etc. - pub fn related_inputs(&self) -> Vec<&str> { - let mut inputs = Vec::with_capacity(4 + self.mail.len()); - self.mail.iter().for_each(|m| { - inputs.push(m.as_str()); - }); - inputs.push(self.name.as_str()); - inputs.push(self.spn.as_str()); - inputs.push(self.displayname.as_str()); - if let Some(s) = self.radius_secret.as_deref() { - inputs.push(s); - } - inputs - } - - pub(crate) fn verify_unix_credential( - &self, - cleartext: &str, - async_tx: &Sender, - ct: Duration, - ) -> Result, OperationError> { - // Is the cred locked? - // NOW checked by the caller! - - /* - if !self.is_within_valid_time(ct) { - lsecurity!(au, "Account is not within valid time period"); - return Ok(None); - } - */ - - // is the cred some or none? - match &self.cred { - Some(cred) => { - cred.password_ref().and_then(|pw| { - let valid = pw.verify(cleartext).map_err(|e| { - error!(crypto_err = ?e); - e.into() - })?; - if valid { - security_info!("Successful unix cred handling"); - if pw.requires_upgrade() { - async_tx - .send(DelayedAction::UnixPwUpgrade(UnixPasswordUpgrade { - target_uuid: self.uuid, - existing_password: cleartext.to_string(), - })) - .map_err(|_| { - admin_error!( - "failed to queue delayed action - unix password upgrade" - ); - OperationError::InvalidState - })?; - } - - // Technically this means we check the times twice, but that doesn't - // seem like a big deal when we want to short cut return on invalid. - Some(self.to_unixusertoken(ct)).transpose() - } else { - // Failed to auth - security_info!("Failed unix cred handling (denied)"); - Ok(None) - } - }) - } - // They don't have a unix cred, fail the auth. - None => { - security_info!("Failed unix cred handling (no cred present)"); - Ok(None) - } - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct UnixGroup { - pub name: String, - pub spn: String, - pub gidnumber: u32, - pub uuid: Uuid, -} - -macro_rules! try_from_group_e { - ($value:expr) => {{ - // We could be looking at a user for their UPG, OR a true group. - - if !(($value.attribute_equality(Attribute::Class, &EntryClass::Account.to_partialvalue()) - && $value.attribute_equality( - Attribute::Class, - &EntryClass::PosixAccount.to_partialvalue(), - )) - || ($value.attribute_equality(Attribute::Class, &EntryClass::Group.to_partialvalue()) - && $value.attribute_equality( - Attribute::Class, - &EntryClass::PosixGroup.to_partialvalue(), - ))) - { - return Err(OperationError::InvalidAccountState(format!( - "Missing {}: {} && {} OR {} && {}", - Attribute::Class, - Attribute::Account, - EntryClass::PosixAccount, - Attribute::Group, - EntryClass::PosixGroup, - ))); - } - - let name = $value - .get_ava_single_iname(Attribute::Name) - .map(|s| s.to_string()) - .ok_or_else(|| { - OperationError::InvalidAccountState(format!( - "Missing attribute: {}", - Attribute::Name - )) - })?; - - let spn = $value - .get_ava_single_proto_string(Attribute::Spn) - .ok_or_else(|| { - OperationError::InvalidAccountState(format!( - "Missing attribute: {}", - Attribute::Spn - )) - })?; - - let uuid = $value.get_uuid(); - - let gidnumber = $value - .get_ava_single_uint32(Attribute::GidNumber) - .ok_or_else(|| { - OperationError::InvalidAccountState(format!( - "Missing attribute: {}", - Attribute::GidNumber - )) - })?; - - Ok(UnixGroup { - name, - spn, - gidnumber, - uuid, - }) - }}; -} - -macro_rules! try_from_account_group_e { - ($value:expr, $qs:expr) => {{ - // First synthesise the self-group from the account. - // We have already checked these, but paranoia is better than - // complacency. - if !$value.attribute_equality(Attribute::Class, &EntryClass::Account.to_partialvalue()) { - return Err(OperationError::InvalidAccountState(format!( - "Missing class: {}", - EntryClass::Account - ))); - } - - if !$value.attribute_equality( - Attribute::Class, - &EntryClass::PosixAccount.to_partialvalue(), - ) { - return Err(OperationError::InvalidAccountState(format!( - "Missing class: {}", - EntryClass::PosixAccount - ))); - } - - let name = $value - .get_ava_single_iname(Attribute::Name) - .map(|s| s.to_string()) - .ok_or_else(|| { - OperationError::InvalidAccountState(format!( - "Missing attribute: {}", - Attribute::Name - )) - })?; - - let spn = $value - .get_ava_single_proto_string(Attribute::Spn) - .ok_or_else(|| { - OperationError::InvalidAccountState(format!( - "Missing attribute: {}", - Attribute::Spn - )) - })?; - - let uuid = $value.get_uuid(); - - let gidnumber = $value - .get_ava_single_uint32(Attribute::GidNumber) - .ok_or_else(|| { - OperationError::InvalidAccountState(format!( - "Missing attribute: {}", - Attribute::GidNumber - )) - })?; - - // This is the user private group. - let upg = UnixGroup { - name, - spn, - gidnumber, - uuid, - }; - - match $value.get_ava_as_refuuid(Attribute::MemberOf) { - Some(riter) => { - let f = filter!(f_and!([ - f_eq(Attribute::Class, EntryClass::PosixGroup.into()), - f_eq(Attribute::Class, EntryClass::Group.into()), - f_or( - riter - .map(|u| f_eq(Attribute::Uuid, PartialValue::Uuid(u))) - .collect() - ) - ])); - let group_entries: Vec<_> = $qs.internal_search(f)?; - let groups: Result, _> = iter::once(Ok(upg)) - .chain( - group_entries - .iter() - .map(|e| UnixGroup::try_from_entry(e.as_ref())), - ) - .collect(); - groups - } - None => { - // No memberof, no groups! - Ok(vec![upg]) - } - } - }}; -} - -impl UnixGroup { - pub(crate) fn try_from_account_entry_rw( - value: &Entry, - qs: &mut QueryServerWriteTransaction, - ) -> Result, OperationError> { - try_from_account_group_e!(value, qs) - } - - pub(crate) fn try_from_account_entry_ro( - value: &Entry, - qs: &mut QueryServerReadTransaction, - ) -> Result, OperationError> { - try_from_account_group_e!(value, qs) - } - - /* - pub fn try_from_account_entry_red_ro( - value: &Entry, - qs: &mut QueryServerReadTransaction, - ) -> Result, OperationError> { - try_from_account_group_e!(au, value, qs) - } - */ - - pub(crate) fn try_from_entry_reduced( - value: &Entry, - ) -> Result { - try_from_group_e!(value) - } - - pub(crate) fn try_from_entry( - value: &Entry, - ) -> Result { - try_from_group_e!(value) - } - - pub(crate) fn to_unixgrouptoken(&self) -> Result { - Ok(UnixGroupToken { - name: self.name.clone(), - spn: self.spn.clone(), - uuid: self.uuid, - gidnumber: self.gidnumber, - }) - } -} diff --git a/server/lib/src/server/migrations.rs b/server/lib/src/server/migrations.rs index 04f27e1d8..1075be870 100644 --- a/server/lib/src/server/migrations.rs +++ b/server/lib/src/server/migrations.rs @@ -584,6 +584,8 @@ impl<'a> QueryServerWriteTransaction<'a> { SCHEMA_CLASS_APPLICATION_DL8.clone().into(), SCHEMA_CLASS_PERSON_DL8.clone().into(), SCHEMA_CLASS_DOMAIN_INFO_DL8.clone().into(), + SCHEMA_ATTR_ALLOW_PRIMARY_CRED_FALLBACK_DL8.clone().into(), + SCHEMA_CLASS_ACCOUNT_POLICY_DL8.clone().into(), ]; idm_schema_classes @@ -608,6 +610,7 @@ impl<'a> QueryServerWriteTransaction<'a> { BUILTIN_IDM_MAIL_SERVERS_DL8.clone().try_into()?, IDM_ACP_MAIL_SERVERS_DL8.clone().into(), IDM_ACP_DOMAIN_ADMIN_DL8.clone().into(), + IDM_ACP_GROUP_ACCOUNT_POLICY_MANAGE_DL8.clone().into(), ]; idm_data diff --git a/tools/cli/src/cli/group/account_policy.rs b/tools/cli/src/cli/group/account_policy.rs index d83d0b004..1f5b6603d 100644 --- a/tools/cli/src/cli/group/account_policy.rs +++ b/tools/cli/src/cli/group/account_policy.rs @@ -11,6 +11,7 @@ impl GroupAccountPolicyOpt { | GroupAccountPolicyOpt::WebauthnAttestationCaList { copt, .. } | GroupAccountPolicyOpt::LimitSearchMaxResults { copt, .. } | GroupAccountPolicyOpt::LimitSearchMaxFilterTest { copt, .. } + | GroupAccountPolicyOpt::AllowPrimaryCredFallback { copt, .. } | GroupAccountPolicyOpt::PrivilegedSessionExpiry { copt, .. } => copt.debug, } } @@ -114,6 +115,17 @@ impl GroupAccountPolicyOpt { println!("Updated search maximum filter test limit."); } } + GroupAccountPolicyOpt::AllowPrimaryCredFallback { name, allow, copt } => { + let client = copt.to_client(OpType::Write).await; + if let Err(e) = client + .group_account_policy_allow_primary_cred_fallback(name, *allow) + .await + { + handle_client_error(e, copt.output_mode); + } else { + println!("Updated primary credential fallback policy."); + } + } } } } diff --git a/tools/cli/src/opt/kanidm.rs b/tools/cli/src/opt/kanidm.rs index cadc0009c..31899779e 100644 --- a/tools/cli/src/opt/kanidm.rs +++ b/tools/cli/src/opt/kanidm.rs @@ -235,6 +235,16 @@ pub enum GroupAccountPolicyOpt { #[clap(flatten)] copt: CommonOpt, }, + /// Sets whether during login the primary password can be used + /// as a fallback if no posix password has been defined + #[clap(name = "allow-primary-cred-fallback")] + AllowPrimaryCredFallback { + name: String, + #[clap(name = "allow", action = clap::ArgAction::Set)] + allow: bool, + #[clap(flatten)] + copt: CommonOpt, + }, } #[derive(Debug, Subcommand)]