Feat: Adding POSIX Password fallback (#3067)

* Added Schema for credential fallback
* Added account polcity management to ac migration
* Refactored Ldap & Unix auth to be common
* removed unused methods and renamed unused fields
* Fixed LDAP missing Anonymous logic
* Added CLI argument for configuring primary cred fallback
This commit is contained in:
CEbbinghaus 2024-10-02 19:28:36 +10:00 committed by GitHub
parent 2dbeeaaedb
commit dc4a438c31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 877 additions and 767 deletions

View file

@ -43,6 +43,7 @@
- Wei Jian Gan (weijiangan)
- adamcstephens
- Chris Olstrom (colstrom)
- Christopher-Robin (cebbinghaus)
## Acknowledgements

View file

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

View file

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

View file

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

View file

@ -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![

View file

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

View file

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

View file

@ -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<Credential>,
_shell: Option<String>,
shell: Option<String>,
sshkeys: BTreeMap<String, SshPublicKey>,
_gidnumber: u32,
gidnumber: u32,
groups: Vec<UnixGroup>,
}
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<EntrySealed, EntryCommitted>,
qs: &mut QueryServerReadTransaction,
) -> Result<Self, OperationError> {
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<EntrySealed, EntryCommitted>,
qs: &mut QueryServerWriteTransaction,
) -> Result<Self, OperationError> {
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<EntryReduced, EntryCommitted>,
qs: &mut QueryServerReadTransaction,
) -> Result<Self, OperationError> {
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<UnixUserToken, OperationError> {
let (gidnumber, shell, sshkeys, groups) = match &self.unix_extn {
Some(ue) => {
let sshkeys: Vec<String> = 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<UnixGroupToken> = 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" ...

View file

@ -12,6 +12,7 @@ pub(crate) struct AccountPolicy {
webauthn_att_ca_list: Option<AttestationCaList>,
limit_search_max_filter_test: Option<u64>,
limit_search_max_results: Option<u64>,
allow_primary_cred_fallback: Option<bool>,
}
impl From<&EntrySealedCommitted> for Option<AccountPolicy> {
@ -51,6 +52,9 @@ impl From<&EntrySealedCommitted> for Option<AccountPolicy> {
.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<AccountPolicy> {
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<AttestationCaList>,
limit_search_max_filter_test: Option<u64>,
limit_search_max_results: Option<u64>,
allow_primary_cred_fallback: Option<bool>,
}
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<u64> {
self.limit_search_max_filter_test
}
pub(crate) fn allow_primary_cred_fallback(&self) -> Option<bool> {
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();

View file

@ -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<UiHint>,
}
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<Group> = vec![];
let mut unix_groups: Vec<UnixGroup> = 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<EntrySealed, EntryCommitted>,
qs: &mut T,
) -> Result<(Vec<Group>, Vec<UnixGroup>), 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<EntrySealed, EntryCommitted>,
qs: &mut T,
) -> Result<((Vec<Group>, Vec<UnixGroup>), 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<AccountPolicy> = 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<EntryReduced, EntryCommitted>,
qs: &mut T,
) -> Result<(Vec<Group>, Vec<UnixGroup>), 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<UiHint>,
}
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<EntrySealed, EntryCommitted>,
qs: &mut TXN,
) -> Result<(Vec<Self>, 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<AccountPolicy> = 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<EntrySealed, EntryCommitted>,
) -> Result<Self, OperationError> {
@ -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<Vec<_>, _> = 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<EntrySealed, EntryCommitted>,
qs: &mut QueryServerWriteTransaction,
) -> Result<Vec<Self>, OperationError> {
try_from_account_group_e!(value, qs)
}
pub(crate) fn try_from_entry_reduced(
value: &Entry<EntryReduced, EntryCommitted>,
) -> Result<Self, OperationError> {
try_from_group_e!(value)
}
pub(crate) fn try_from_entry(
value: &Entry<EntrySealed, EntryCommitted>,
) -> Result<Self, OperationError> {
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,
}
}
}

View file

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

View file

@ -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<Option<Account>, 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<Option<UnixUserToken>, 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<Option<LdapBoundToken>, 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<Option<LdapBoundToken>, 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<Option<LdapBoundToken>, 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<ModifyList<ModifyInvalid>, 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<Option<ModifyList<ModifyInvalid>>, 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<Jwk, OperationError> {
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<bool>,
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;
}
}

View file

@ -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<OffsetDateTime>,
pub expire: Option<OffsetDateTime>,
pub radius_secret: Option<String>,
pub mail: Vec<String>,
cred: Option<Credential>,
pub shell: Option<String>,
pub sshkeys: Vec<String>,
pub gidnumber: u32,
pub groups: Vec<UnixGroup>,
}
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<EntrySealed, EntryCommitted>,
qs: &mut QueryServerWriteTransaction,
) -> Result<Self, OperationError> {
let groups = UnixGroup::try_from_account_entry_rw(value, qs)?;
try_from_entry!(value, groups)
}
pub(crate) fn try_from_entry_ro(
value: &Entry<EntrySealed, EntryCommitted>,
qs: &mut QueryServerReadTransaction,
) -> Result<Self, OperationError> {
let groups = UnixGroup::try_from_account_entry_ro(value, qs)?;
try_from_entry!(value, groups)
}
pub(crate) fn to_unixusertoken(&self, ct: Duration) -> Result<UnixUserToken, OperationError> {
let groups: Result<Vec<_>, _> = 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<ModifyList<ModifyInvalid>, 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<Option<ModifyList<ModifyInvalid>>, 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<DelayedAction>,
ct: Duration,
) -> Result<Option<UnixUserToken>, 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<Vec<_>, _> = 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<EntrySealed, EntryCommitted>,
qs: &mut QueryServerWriteTransaction,
) -> Result<Vec<Self>, OperationError> {
try_from_account_group_e!(value, qs)
}
pub(crate) fn try_from_account_entry_ro(
value: &Entry<EntrySealed, EntryCommitted>,
qs: &mut QueryServerReadTransaction,
) -> Result<Vec<Self>, OperationError> {
try_from_account_group_e!(value, qs)
}
/*
pub fn try_from_account_entry_red_ro(
value: &Entry<EntryReduced, EntryCommitted>,
qs: &mut QueryServerReadTransaction,
) -> Result<Vec<Self>, OperationError> {
try_from_account_group_e!(au, value, qs)
}
*/
pub(crate) fn try_from_entry_reduced(
value: &Entry<EntryReduced, EntryCommitted>,
) -> Result<Self, OperationError> {
try_from_group_e!(value)
}
pub(crate) fn try_from_entry(
value: &Entry<EntrySealed, EntryCommitted>,
) -> Result<Self, OperationError> {
try_from_group_e!(value)
}
pub(crate) fn to_unixgrouptoken(&self) -> Result<UnixGroupToken, OperationError> {
Ok(UnixGroupToken {
name: self.name.clone(),
spn: self.spn.clone(),
uuid: self.uuid,
gidnumber: self.gidnumber,
})
}
}

View file

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

View file

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

View file

@ -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)]