mirror of
https://github.com/kanidm/kanidm.git
synced 2025-05-22 00:43:54 +02:00
4817 lines
176 KiB
Rust
4817 lines
176 KiB
Rust
use core::ops::Deref;
|
|
use std::collections::BTreeMap;
|
|
use std::fmt::{self, Display};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use sshkey_attest::proto::PublicKey as SshPublicKey;
|
|
|
|
use hashbrown::HashSet;
|
|
use kanidm_proto::internal::{
|
|
CUCredState, CUExtPortal, CURegState, CURegWarning, CUStatus, CredentialDetail, PasskeyDetail,
|
|
PasswordFeedback, TotpSecret,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use time::OffsetDateTime;
|
|
use webauthn_rs::prelude::{
|
|
AttestedPasskey as AttestedPasskeyV4, AttestedPasskeyRegistration, CreationChallengeResponse,
|
|
Passkey as PasskeyV4, PasskeyRegistration, RegisterPublicKeyCredential, WebauthnError,
|
|
};
|
|
use zxcvbn::{zxcvbn, Score};
|
|
|
|
use crate::credential::totp::{Totp, TOTP_DEFAULT_STEP};
|
|
use crate::credential::{BackupCodes, Credential};
|
|
use crate::idm::account::Account;
|
|
use crate::idm::server::{IdmServerCredUpdateTransaction, IdmServerProxyWriteTransaction};
|
|
use crate::prelude::*;
|
|
use crate::server::access::Access;
|
|
use crate::utils::{backup_code_from_random, readable_password_from_random, uuid_from_duration};
|
|
use crate::value::{CredUpdateSessionPerms, CredentialType, IntentTokenState, LABEL_RE};
|
|
use compact_jwt::compact::JweCompact;
|
|
use compact_jwt::jwe::JweBuilder;
|
|
|
|
use super::accountpolicy::ResolvedAccountPolicy;
|
|
|
|
// A user can take up to 15 minutes to update their credentials before we automatically
|
|
// cancel on them.
|
|
const MAXIMUM_CRED_UPDATE_TTL: Duration = Duration::from_secs(900);
|
|
// Minimum 5 minutes.
|
|
const MINIMUM_INTENT_TTL: Duration = Duration::from_secs(300);
|
|
// Default 1 hour.
|
|
const DEFAULT_INTENT_TTL: Duration = Duration::from_secs(3600);
|
|
// Default 1 day.
|
|
const MAXIMUM_INTENT_TTL: Duration = Duration::from_secs(86400);
|
|
|
|
#[derive(Debug)]
|
|
pub enum PasswordQuality {
|
|
TooShort(u32),
|
|
BadListed,
|
|
DontReusePasswords,
|
|
Feedback(Vec<PasswordFeedback>),
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct CredentialUpdateIntentToken {
|
|
pub intent_id: String,
|
|
pub expiry_time: OffsetDateTime,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct CredentialUpdateIntentTokenExchange {
|
|
pub intent_id: String,
|
|
}
|
|
|
|
impl From<CredentialUpdateIntentToken> for CredentialUpdateIntentTokenExchange {
|
|
fn from(tok: CredentialUpdateIntentToken) -> Self {
|
|
CredentialUpdateIntentTokenExchange {
|
|
intent_id: tok.intent_id,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
struct CredentialUpdateSessionTokenInner {
|
|
pub sessionid: Uuid,
|
|
// How long is it valid for?
|
|
pub max_ttl: Duration,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct CredentialUpdateSessionToken {
|
|
pub token_enc: JweCompact,
|
|
}
|
|
|
|
/// The current state of MFA registration
|
|
#[derive(Clone)]
|
|
enum MfaRegState {
|
|
None,
|
|
TotpInit(Totp),
|
|
TotpTryAgain(Totp),
|
|
TotpNameTryAgain(Totp, String),
|
|
TotpInvalidSha1(Totp, Totp, String),
|
|
Passkey(Box<CreationChallengeResponse>, PasskeyRegistration),
|
|
#[allow(dead_code)]
|
|
AttestedPasskey(Box<CreationChallengeResponse>, AttestedPasskeyRegistration),
|
|
}
|
|
|
|
impl fmt::Debug for MfaRegState {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let t = match self {
|
|
MfaRegState::None => "MfaRegState::None",
|
|
MfaRegState::TotpInit(_) => "MfaRegState::TotpInit",
|
|
MfaRegState::TotpTryAgain(_) => "MfaRegState::TotpTryAgain",
|
|
MfaRegState::TotpNameTryAgain(_, _) => "MfaRegState::TotpNameTryAgain",
|
|
MfaRegState::TotpInvalidSha1(_, _, _) => "MfaRegState::TotpInvalidSha1",
|
|
MfaRegState::Passkey(_, _) => "MfaRegState::Passkey",
|
|
MfaRegState::AttestedPasskey(_, _) => "MfaRegState::AttestedPasskey",
|
|
};
|
|
write!(f, "{t}")
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum CredentialState {
|
|
Modifiable,
|
|
DeleteOnly,
|
|
AccessDeny,
|
|
PolicyDeny,
|
|
// Disabled,
|
|
}
|
|
|
|
impl From<CredentialState> for CUCredState {
|
|
fn from(val: CredentialState) -> CUCredState {
|
|
match val {
|
|
CredentialState::Modifiable => CUCredState::Modifiable,
|
|
CredentialState::DeleteOnly => CUCredState::DeleteOnly,
|
|
CredentialState::AccessDeny => CUCredState::AccessDeny,
|
|
CredentialState::PolicyDeny => CUCredState::PolicyDeny,
|
|
// CredentialState::Disabled => CUCredState::Disabled ,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct CredentialUpdateSession {
|
|
issuer: String,
|
|
// Current credentials - these are on the Account!
|
|
account: Account,
|
|
// The account policy applied to this account
|
|
resolved_account_policy: ResolvedAccountPolicy,
|
|
// What intent was used to initiate this session.
|
|
intent_token_id: Option<String>,
|
|
|
|
// Is there an extertal credential portal?
|
|
ext_cred_portal: CUExtPortal,
|
|
|
|
// The pw credential as they are being updated
|
|
primary_state: CredentialState,
|
|
primary: Option<Credential>,
|
|
|
|
// Unix / Sudo PW
|
|
unixcred: Option<Credential>,
|
|
unixcred_state: CredentialState,
|
|
|
|
// Ssh Keys
|
|
sshkeys: BTreeMap<String, SshPublicKey>,
|
|
sshkeys_state: CredentialState,
|
|
|
|
// Passkeys that have been configured.
|
|
passkeys: BTreeMap<Uuid, (String, PasskeyV4)>,
|
|
passkeys_state: CredentialState,
|
|
|
|
// Attested Passkeys
|
|
attested_passkeys: BTreeMap<Uuid, (String, AttestedPasskeyV4)>,
|
|
attested_passkeys_state: CredentialState,
|
|
|
|
// Internal reg state of any inprogress totp or webauthn credentials.
|
|
mfaregstate: MfaRegState,
|
|
}
|
|
|
|
impl fmt::Debug for CredentialUpdateSession {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let primary: Option<CredentialDetail> = self.primary.as_ref().map(|c| c.into());
|
|
let passkeys: Vec<PasskeyDetail> = self
|
|
.passkeys
|
|
.iter()
|
|
.map(|(uuid, (tag, _pk))| PasskeyDetail {
|
|
tag: tag.clone(),
|
|
uuid: *uuid,
|
|
})
|
|
.collect();
|
|
let attested_passkeys: Vec<PasskeyDetail> = self
|
|
.attested_passkeys
|
|
.iter()
|
|
.map(|(uuid, (tag, _pk))| PasskeyDetail {
|
|
tag: tag.clone(),
|
|
uuid: *uuid,
|
|
})
|
|
.collect();
|
|
f.debug_struct("CredentialUpdateSession")
|
|
.field("account.spn", &self.account.spn)
|
|
.field("account.unix", &self.account.unix_extn().is_some())
|
|
.field("resolved_account_policy", &self.resolved_account_policy)
|
|
.field("intent_token_id", &self.intent_token_id)
|
|
.field("primary.detail()", &primary)
|
|
.field("primary.state", &self.primary_state)
|
|
.field("passkeys.list()", &passkeys)
|
|
.field("passkeys.state", &self.passkeys_state)
|
|
.field("attested_passkeys.list()", &attested_passkeys)
|
|
.field("attested_passkeys.state", &self.attested_passkeys_state)
|
|
.field("mfaregstate", &self.mfaregstate)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl CredentialUpdateSession {
|
|
// Vec of the issues with the current session so that UI's can highlight properly how to proceed.
|
|
fn can_commit(&self) -> (bool, Vec<CredentialUpdateSessionStatusWarnings>) {
|
|
let mut warnings = Vec::with_capacity(0);
|
|
let mut can_proceed = true;
|
|
|
|
let cred_type_min = self.resolved_account_policy.credential_policy();
|
|
|
|
debug!(?cred_type_min);
|
|
|
|
match cred_type_min {
|
|
CredentialType::Any => {}
|
|
CredentialType::Mfa => {
|
|
if self
|
|
.primary
|
|
.as_ref()
|
|
.map(|cred| !cred.is_mfa())
|
|
// If it's none, then we can proceed because we satisfy mfa on other
|
|
// parts.
|
|
.unwrap_or(false)
|
|
{
|
|
can_proceed = false;
|
|
warnings.push(CredentialUpdateSessionStatusWarnings::MfaRequired);
|
|
}
|
|
}
|
|
CredentialType::Passkey => {
|
|
// NOTE: Technically this is unreachable, but we keep it for correctness.
|
|
// Primary can't be set at all.
|
|
if self.primary.is_some() {
|
|
can_proceed = false;
|
|
warnings.push(CredentialUpdateSessionStatusWarnings::PasskeyRequired);
|
|
}
|
|
}
|
|
CredentialType::AttestedPasskey => {
|
|
// Also unreachable - during these sessions, there will be no values present here.
|
|
if !self.passkeys.is_empty() || self.primary.is_some() {
|
|
can_proceed = false;
|
|
warnings.push(CredentialUpdateSessionStatusWarnings::AttestedPasskeyRequired);
|
|
}
|
|
}
|
|
CredentialType::AttestedResidentkey => {
|
|
// Also unreachable - during these sessions, there will be no values present here.
|
|
if !self.attested_passkeys.is_empty()
|
|
|| !self.passkeys.is_empty()
|
|
|| self.primary.is_some()
|
|
{
|
|
can_proceed = false;
|
|
warnings
|
|
.push(CredentialUpdateSessionStatusWarnings::AttestedResidentKeyRequired);
|
|
}
|
|
}
|
|
CredentialType::Invalid => {
|
|
// special case, must always deny all changes.
|
|
can_proceed = false;
|
|
warnings.push(CredentialUpdateSessionStatusWarnings::Unsatisfiable)
|
|
}
|
|
}
|
|
|
|
if let Some(att_ca_list) = self.resolved_account_policy.webauthn_attestation_ca_list() {
|
|
if att_ca_list.is_empty() {
|
|
warnings
|
|
.push(CredentialUpdateSessionStatusWarnings::WebauthnAttestationUnsatisfiable)
|
|
}
|
|
}
|
|
|
|
(can_proceed, warnings)
|
|
}
|
|
}
|
|
|
|
pub enum MfaRegStateStatus {
|
|
// Nothing in progress.
|
|
None,
|
|
TotpCheck(TotpSecret),
|
|
TotpTryAgain,
|
|
TotpNameTryAgain(String),
|
|
TotpInvalidSha1,
|
|
BackupCodes(HashSet<String>),
|
|
Passkey(CreationChallengeResponse),
|
|
AttestedPasskey(CreationChallengeResponse),
|
|
}
|
|
|
|
impl fmt::Debug for MfaRegStateStatus {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let t = match self {
|
|
MfaRegStateStatus::None => "MfaRegStateStatus::None",
|
|
MfaRegStateStatus::TotpCheck(_) => "MfaRegStateStatus::TotpCheck",
|
|
MfaRegStateStatus::TotpTryAgain => "MfaRegStateStatus::TotpTryAgain",
|
|
MfaRegStateStatus::TotpNameTryAgain(_) => "MfaRegStateStatus::TotpNameTryAgain",
|
|
MfaRegStateStatus::TotpInvalidSha1 => "MfaRegStateStatus::TotpInvalidSha1",
|
|
MfaRegStateStatus::BackupCodes(_) => "MfaRegStateStatus::BackupCodes",
|
|
MfaRegStateStatus::Passkey(_) => "MfaRegStateStatus::Passkey",
|
|
MfaRegStateStatus::AttestedPasskey(_) => "MfaRegStateStatus::AttestedPasskey",
|
|
};
|
|
write!(f, "{t}")
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
|
pub enum CredentialUpdateSessionStatusWarnings {
|
|
MfaRequired,
|
|
PasskeyRequired,
|
|
AttestedPasskeyRequired,
|
|
AttestedResidentKeyRequired,
|
|
Unsatisfiable,
|
|
WebauthnAttestationUnsatisfiable,
|
|
WebauthnUserVerificationRequired,
|
|
}
|
|
|
|
impl Display for CredentialUpdateSessionStatusWarnings {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
|
write!(f, "{:?}", self)
|
|
}
|
|
}
|
|
|
|
impl From<CredentialUpdateSessionStatusWarnings> for CURegWarning {
|
|
fn from(val: CredentialUpdateSessionStatusWarnings) -> CURegWarning {
|
|
match val {
|
|
CredentialUpdateSessionStatusWarnings::MfaRequired => CURegWarning::MfaRequired,
|
|
CredentialUpdateSessionStatusWarnings::PasskeyRequired => CURegWarning::PasskeyRequired,
|
|
CredentialUpdateSessionStatusWarnings::AttestedPasskeyRequired => {
|
|
CURegWarning::AttestedPasskeyRequired
|
|
}
|
|
CredentialUpdateSessionStatusWarnings::AttestedResidentKeyRequired => {
|
|
CURegWarning::AttestedResidentKeyRequired
|
|
}
|
|
CredentialUpdateSessionStatusWarnings::Unsatisfiable => CURegWarning::Unsatisfiable,
|
|
CredentialUpdateSessionStatusWarnings::WebauthnAttestationUnsatisfiable => {
|
|
CURegWarning::WebauthnAttestationUnsatisfiable
|
|
}
|
|
CredentialUpdateSessionStatusWarnings::WebauthnUserVerificationRequired => {
|
|
CURegWarning::WebauthnUserVerificationRequired
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct CredentialUpdateSessionStatus {
|
|
spn: String,
|
|
// The target user's display name
|
|
displayname: String,
|
|
ext_cred_portal: CUExtPortal,
|
|
// Any info the client needs about mfareg state.
|
|
mfaregstate: MfaRegStateStatus,
|
|
can_commit: bool,
|
|
// If can_commit is false, this will have warnings populated.
|
|
warnings: Vec<CredentialUpdateSessionStatusWarnings>,
|
|
primary: Option<CredentialDetail>,
|
|
primary_state: CredentialState,
|
|
passkeys: Vec<PasskeyDetail>,
|
|
passkeys_state: CredentialState,
|
|
attested_passkeys: Vec<PasskeyDetail>,
|
|
attested_passkeys_state: CredentialState,
|
|
attested_passkeys_allowed_devices: Vec<String>,
|
|
|
|
unixcred: Option<CredentialDetail>,
|
|
unixcred_state: CredentialState,
|
|
|
|
sshkeys: BTreeMap<String, SshPublicKey>,
|
|
sshkeys_state: CredentialState,
|
|
}
|
|
|
|
impl CredentialUpdateSessionStatus {
|
|
/// Append a single warning to this session status, which will only be displayed to the
|
|
/// user once. This is different to other warnings that are derived from the state of the
|
|
/// session as a whole.
|
|
pub fn append_ephemeral_warning(&mut self, warning: CredentialUpdateSessionStatusWarnings) {
|
|
self.warnings.push(warning)
|
|
}
|
|
|
|
pub fn can_commit(&self) -> bool {
|
|
self.can_commit
|
|
}
|
|
|
|
pub fn mfaregstate(&self) -> &MfaRegStateStatus {
|
|
&self.mfaregstate
|
|
}
|
|
}
|
|
|
|
// We allow Into here because CUStatus is foreign so it's impossible for us to implement From
|
|
// in a valid manner
|
|
#[allow(clippy::from_over_into)]
|
|
impl Into<CUStatus> for CredentialUpdateSessionStatus {
|
|
fn into(self) -> CUStatus {
|
|
CUStatus {
|
|
spn: self.spn,
|
|
displayname: self.displayname,
|
|
ext_cred_portal: self.ext_cred_portal,
|
|
mfaregstate: match self.mfaregstate {
|
|
MfaRegStateStatus::None => CURegState::None,
|
|
MfaRegStateStatus::TotpCheck(c) => CURegState::TotpCheck(c),
|
|
MfaRegStateStatus::TotpTryAgain => CURegState::TotpTryAgain,
|
|
MfaRegStateStatus::TotpNameTryAgain(label) => CURegState::TotpNameTryAgain(label),
|
|
MfaRegStateStatus::TotpInvalidSha1 => CURegState::TotpInvalidSha1,
|
|
MfaRegStateStatus::BackupCodes(s) => {
|
|
CURegState::BackupCodes(s.into_iter().collect())
|
|
}
|
|
MfaRegStateStatus::Passkey(r) => CURegState::Passkey(r),
|
|
MfaRegStateStatus::AttestedPasskey(r) => CURegState::AttestedPasskey(r),
|
|
},
|
|
can_commit: self.can_commit,
|
|
warnings: self.warnings.into_iter().map(|w| w.into()).collect(),
|
|
primary: self.primary,
|
|
primary_state: self.primary_state.into(),
|
|
passkeys: self.passkeys,
|
|
passkeys_state: self.passkeys_state.into(),
|
|
attested_passkeys: self.attested_passkeys,
|
|
attested_passkeys_state: self.attested_passkeys_state.into(),
|
|
attested_passkeys_allowed_devices: self.attested_passkeys_allowed_devices,
|
|
unixcred: self.unixcred,
|
|
unixcred_state: self.unixcred_state.into(),
|
|
sshkeys: self.sshkeys,
|
|
sshkeys_state: self.sshkeys_state.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&CredentialUpdateSession> for CredentialUpdateSessionStatus {
|
|
fn from(session: &CredentialUpdateSession) -> Self {
|
|
let (can_commit, warnings) = session.can_commit();
|
|
|
|
let attested_passkeys_allowed_devices: Vec<String> = session
|
|
.resolved_account_policy
|
|
.webauthn_attestation_ca_list()
|
|
.iter()
|
|
.flat_map(|att_ca_list: &&webauthn_rs::prelude::AttestationCaList| {
|
|
att_ca_list.cas().values().flat_map(|ca| {
|
|
ca.aaguids()
|
|
.values()
|
|
.map(|device| device.description_en().to_string())
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
CredentialUpdateSessionStatus {
|
|
spn: session.account.spn.clone(),
|
|
displayname: session.account.displayname.clone(),
|
|
ext_cred_portal: session.ext_cred_portal.clone(),
|
|
can_commit,
|
|
warnings,
|
|
primary: session.primary.as_ref().map(|c| c.into()),
|
|
primary_state: session.primary_state,
|
|
passkeys: session
|
|
.passkeys
|
|
.iter()
|
|
.map(|(uuid, (tag, _pk))| PasskeyDetail {
|
|
tag: tag.clone(),
|
|
uuid: *uuid,
|
|
})
|
|
.collect(),
|
|
passkeys_state: session.passkeys_state,
|
|
attested_passkeys: session
|
|
.attested_passkeys
|
|
.iter()
|
|
.map(|(uuid, (tag, _pk))| PasskeyDetail {
|
|
tag: tag.clone(),
|
|
uuid: *uuid,
|
|
})
|
|
.collect(),
|
|
attested_passkeys_state: session.attested_passkeys_state,
|
|
attested_passkeys_allowed_devices,
|
|
|
|
unixcred: session.unixcred.as_ref().map(|c| c.into()),
|
|
unixcred_state: session.unixcred_state,
|
|
|
|
sshkeys: session.sshkeys.clone(),
|
|
sshkeys_state: session.sshkeys_state,
|
|
|
|
mfaregstate: match &session.mfaregstate {
|
|
MfaRegState::None => MfaRegStateStatus::None,
|
|
MfaRegState::TotpInit(token) => MfaRegStateStatus::TotpCheck(
|
|
token.to_proto(session.account.name.as_str(), session.issuer.as_str()),
|
|
),
|
|
MfaRegState::TotpNameTryAgain(_, name) => {
|
|
MfaRegStateStatus::TotpNameTryAgain(name.clone())
|
|
}
|
|
MfaRegState::TotpTryAgain(_) => MfaRegStateStatus::TotpTryAgain,
|
|
MfaRegState::TotpInvalidSha1(_, _, _) => MfaRegStateStatus::TotpInvalidSha1,
|
|
MfaRegState::Passkey(r, _) => MfaRegStateStatus::Passkey(r.as_ref().clone()),
|
|
MfaRegState::AttestedPasskey(r, _) => {
|
|
MfaRegStateStatus::AttestedPasskey(r.as_ref().clone())
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) type CredentialUpdateSessionMutex = Arc<Mutex<CredentialUpdateSession>>;
|
|
|
|
pub struct InitCredentialUpdateIntentEvent {
|
|
// Who initiated this?
|
|
pub ident: Identity,
|
|
// Who is it targeting?
|
|
pub target: Uuid,
|
|
// How long is it valid for?
|
|
pub max_ttl: Option<Duration>,
|
|
}
|
|
|
|
impl InitCredentialUpdateIntentEvent {
|
|
pub fn new(ident: Identity, target: Uuid, max_ttl: Option<Duration>) -> Self {
|
|
InitCredentialUpdateIntentEvent {
|
|
ident,
|
|
target,
|
|
max_ttl,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn new_impersonate_entry(
|
|
e: std::sync::Arc<Entry<EntrySealed, EntryCommitted>>,
|
|
target: Uuid,
|
|
max_ttl: Duration,
|
|
) -> Self {
|
|
let ident = Identity::from_impersonate_entry_readwrite(e);
|
|
InitCredentialUpdateIntentEvent {
|
|
ident,
|
|
target,
|
|
max_ttl: Some(max_ttl),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct InitCredentialUpdateEvent {
|
|
pub ident: Identity,
|
|
pub target: Uuid,
|
|
}
|
|
|
|
impl InitCredentialUpdateEvent {
|
|
pub fn new(ident: Identity, target: Uuid) -> Self {
|
|
InitCredentialUpdateEvent { ident, target }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn new_impersonate_entry(e: std::sync::Arc<Entry<EntrySealed, EntryCommitted>>) -> Self {
|
|
let ident = Identity::from_impersonate_entry_readwrite(e);
|
|
|
|
let target = ident
|
|
.get_uuid()
|
|
.ok_or(OperationError::InvalidState)
|
|
.expect("Identity has no uuid associated");
|
|
InitCredentialUpdateEvent { ident, target }
|
|
}
|
|
}
|
|
|
|
impl IdmServerProxyWriteTransaction<'_> {
|
|
fn validate_init_credential_update(
|
|
&mut self,
|
|
target: Uuid,
|
|
ident: &Identity,
|
|
) -> Result<(Account, ResolvedAccountPolicy, CredUpdateSessionPerms), OperationError> {
|
|
let entry = self.qs_write.internal_search_uuid(target)?;
|
|
|
|
security_info!(
|
|
%target,
|
|
"Initiating Credential Update Session",
|
|
);
|
|
|
|
// The initiating identity must be in readwrite mode! Effective permission assumes you
|
|
// are in rw.
|
|
if ident.access_scope() != AccessScope::ReadWrite {
|
|
security_access!("identity access scope is not permitted to modify");
|
|
security_access!("denied ❌");
|
|
return Err(OperationError::AccessDenied);
|
|
}
|
|
|
|
// Is target an account? This checks for us.
|
|
let (account, resolved_account_policy) =
|
|
Account::try_from_entry_with_policy(entry.as_ref(), &mut self.qs_write)?;
|
|
|
|
let effective_perms = self
|
|
.qs_write
|
|
.get_accesscontrols()
|
|
.effective_permission_check(
|
|
ident,
|
|
Some(btreeset![
|
|
Attribute::PrimaryCredential,
|
|
Attribute::PassKeys,
|
|
Attribute::AttestedPasskeys,
|
|
Attribute::UnixPassword,
|
|
Attribute::SshPublicKey
|
|
]),
|
|
&[entry],
|
|
)?;
|
|
|
|
let eperm = effective_perms.first().ok_or_else(|| {
|
|
error!("Effective Permission check returned no results");
|
|
OperationError::InvalidState
|
|
})?;
|
|
|
|
// Does the ident have permission to modify AND search the user-credentials of the target, given
|
|
// the current status of it's authentication?
|
|
|
|
if eperm.target != account.uuid {
|
|
error!("Effective Permission check target differs from requested entry uuid");
|
|
return Err(OperationError::InvalidEntryState);
|
|
}
|
|
|
|
let eperm_search_primary_cred = match &eperm.search {
|
|
Access::Deny => false,
|
|
Access::Grant => true,
|
|
Access::Allow(attrs) => attrs.contains(&Attribute::PrimaryCredential),
|
|
};
|
|
|
|
let eperm_mod_primary_cred = match &eperm.modify_pres {
|
|
Access::Deny => false,
|
|
Access::Grant => true,
|
|
Access::Allow(attrs) => attrs.contains(&Attribute::PrimaryCredential),
|
|
};
|
|
|
|
let eperm_rem_primary_cred = match &eperm.modify_rem {
|
|
Access::Deny => false,
|
|
Access::Grant => true,
|
|
Access::Allow(attrs) => attrs.contains(&Attribute::PrimaryCredential),
|
|
};
|
|
|
|
let primary_can_edit =
|
|
eperm_search_primary_cred && eperm_mod_primary_cred && eperm_rem_primary_cred;
|
|
|
|
let eperm_search_passkeys = match &eperm.search {
|
|
Access::Deny => false,
|
|
Access::Grant => true,
|
|
Access::Allow(attrs) => attrs.contains(&Attribute::PassKeys),
|
|
};
|
|
|
|
let eperm_mod_passkeys = match &eperm.modify_pres {
|
|
Access::Deny => false,
|
|
Access::Grant => true,
|
|
Access::Allow(attrs) => attrs.contains(&Attribute::PassKeys),
|
|
};
|
|
|
|
let eperm_rem_passkeys = match &eperm.modify_rem {
|
|
Access::Deny => false,
|
|
Access::Grant => true,
|
|
Access::Allow(attrs) => attrs.contains(&Attribute::PassKeys),
|
|
};
|
|
|
|
let passkeys_can_edit = eperm_search_passkeys && eperm_mod_passkeys && eperm_rem_passkeys;
|
|
|
|
let eperm_search_attested_passkeys = match &eperm.search {
|
|
Access::Deny => false,
|
|
Access::Grant => true,
|
|
Access::Allow(attrs) => attrs.contains(&Attribute::AttestedPasskeys),
|
|
};
|
|
|
|
let eperm_mod_attested_passkeys = match &eperm.modify_pres {
|
|
Access::Deny => false,
|
|
Access::Grant => true,
|
|
Access::Allow(attrs) => attrs.contains(&Attribute::AttestedPasskeys),
|
|
};
|
|
|
|
let eperm_rem_attested_passkeys = match &eperm.modify_rem {
|
|
Access::Deny => false,
|
|
Access::Grant => true,
|
|
Access::Allow(attrs) => attrs.contains(&Attribute::AttestedPasskeys),
|
|
};
|
|
|
|
let attested_passkeys_can_edit = eperm_search_attested_passkeys
|
|
&& eperm_mod_attested_passkeys
|
|
&& eperm_rem_attested_passkeys;
|
|
|
|
let eperm_search_unixcred = match &eperm.search {
|
|
Access::Deny => false,
|
|
Access::Grant => true,
|
|
Access::Allow(attrs) => attrs.contains(&Attribute::UnixPassword),
|
|
};
|
|
|
|
let eperm_mod_unixcred = match &eperm.modify_pres {
|
|
Access::Deny => false,
|
|
Access::Grant => true,
|
|
Access::Allow(attrs) => attrs.contains(&Attribute::UnixPassword),
|
|
};
|
|
|
|
let eperm_rem_unixcred = match &eperm.modify_rem {
|
|
Access::Deny => false,
|
|
Access::Grant => true,
|
|
Access::Allow(attrs) => attrs.contains(&Attribute::UnixPassword),
|
|
};
|
|
|
|
let unixcred_can_edit = account.unix_extn().is_some()
|
|
&& eperm_search_unixcred
|
|
&& eperm_mod_unixcred
|
|
&& eperm_rem_unixcred;
|
|
|
|
let eperm_search_sshpubkey = match &eperm.search {
|
|
Access::Deny => false,
|
|
Access::Grant => true,
|
|
Access::Allow(attrs) => attrs.contains(&Attribute::SshPublicKey),
|
|
};
|
|
|
|
let eperm_mod_sshpubkey = match &eperm.modify_pres {
|
|
Access::Deny => false,
|
|
Access::Grant => true,
|
|
Access::Allow(attrs) => attrs.contains(&Attribute::SshPublicKey),
|
|
};
|
|
|
|
let eperm_rem_sshpubkey = match &eperm.modify_rem {
|
|
Access::Deny => false,
|
|
Access::Grant => true,
|
|
Access::Allow(attrs) => attrs.contains(&Attribute::SshPublicKey),
|
|
};
|
|
|
|
let sshpubkey_can_edit = account.unix_extn().is_some()
|
|
&& eperm_search_sshpubkey
|
|
&& eperm_mod_sshpubkey
|
|
&& eperm_rem_sshpubkey;
|
|
|
|
let ext_cred_portal_can_view = if let Some(sync_parent_uuid) = account.sync_parent_uuid {
|
|
// In theory this is always granted due to how access controls work, but we check anyway.
|
|
let entry = self.qs_write.internal_search_uuid(sync_parent_uuid)?;
|
|
|
|
let effective_perms = self
|
|
.qs_write
|
|
.get_accesscontrols()
|
|
.effective_permission_check(
|
|
ident,
|
|
Some(btreeset![Attribute::SyncCredentialPortal]),
|
|
&[entry],
|
|
)?;
|
|
|
|
let eperm = effective_perms.first().ok_or_else(|| {
|
|
admin_error!("Effective Permission check returned no results");
|
|
OperationError::InvalidState
|
|
})?;
|
|
|
|
match &eperm.search {
|
|
Access::Deny => false,
|
|
Access::Grant => true,
|
|
Access::Allow(attrs) => attrs.contains(&Attribute::SyncCredentialPortal),
|
|
}
|
|
} else {
|
|
false
|
|
};
|
|
|
|
// At lease *one* must be modifiable OR visible.
|
|
if !(primary_can_edit
|
|
|| passkeys_can_edit
|
|
|| attested_passkeys_can_edit
|
|
|| ext_cred_portal_can_view
|
|
|| sshpubkey_can_edit
|
|
|| unixcred_can_edit)
|
|
{
|
|
error!("Unable to proceed with credential update intent - at least one type of credential must be modifiable or visible.");
|
|
Err(OperationError::NotAuthorised)
|
|
} else {
|
|
security_info!(%primary_can_edit, %passkeys_can_edit, %unixcred_can_edit, %sshpubkey_can_edit, %ext_cred_portal_can_view, "Proceeding");
|
|
Ok((
|
|
account,
|
|
resolved_account_policy,
|
|
CredUpdateSessionPerms {
|
|
ext_cred_portal_can_view,
|
|
passkeys_can_edit,
|
|
attested_passkeys_can_edit,
|
|
primary_can_edit,
|
|
unixcred_can_edit,
|
|
sshpubkey_can_edit,
|
|
},
|
|
))
|
|
}
|
|
}
|
|
|
|
fn create_credupdate_session(
|
|
&mut self,
|
|
sessionid: Uuid,
|
|
intent_token_id: Option<String>,
|
|
account: Account,
|
|
resolved_account_policy: ResolvedAccountPolicy,
|
|
perms: CredUpdateSessionPerms,
|
|
ct: Duration,
|
|
) -> Result<(CredentialUpdateSessionToken, CredentialUpdateSessionStatus), OperationError> {
|
|
let ext_cred_portal_can_view = perms.ext_cred_portal_can_view;
|
|
|
|
let cred_type_min = resolved_account_policy.credential_policy();
|
|
|
|
// We can't decide this based on CredentialType alone since we may have CredentialType::Mfa
|
|
// and still need attestation. As a result, we have to decide this based on presence of
|
|
// the attestation policy.
|
|
let passkey_attestation_required = resolved_account_policy
|
|
.webauthn_attestation_ca_list()
|
|
.is_some();
|
|
|
|
let primary_state = if cred_type_min > CredentialType::Mfa {
|
|
CredentialState::PolicyDeny
|
|
} else if perms.primary_can_edit {
|
|
CredentialState::Modifiable
|
|
} else {
|
|
CredentialState::AccessDeny
|
|
};
|
|
|
|
let passkeys_state =
|
|
if cred_type_min > CredentialType::Passkey || passkey_attestation_required {
|
|
CredentialState::PolicyDeny
|
|
} else if perms.passkeys_can_edit {
|
|
CredentialState::Modifiable
|
|
} else {
|
|
CredentialState::AccessDeny
|
|
};
|
|
|
|
let attested_passkeys_state = if cred_type_min > CredentialType::AttestedPasskey {
|
|
CredentialState::PolicyDeny
|
|
} else if perms.attested_passkeys_can_edit {
|
|
if passkey_attestation_required {
|
|
CredentialState::Modifiable
|
|
} else {
|
|
// User can only delete, no police available to add more keys.
|
|
CredentialState::DeleteOnly
|
|
}
|
|
} else {
|
|
CredentialState::AccessDeny
|
|
};
|
|
|
|
let unixcred_state = if account.unix_extn().is_none() {
|
|
CredentialState::PolicyDeny
|
|
} else if perms.unixcred_can_edit {
|
|
CredentialState::Modifiable
|
|
} else {
|
|
CredentialState::AccessDeny
|
|
};
|
|
|
|
let sshkeys_state = if perms.sshpubkey_can_edit {
|
|
CredentialState::Modifiable
|
|
} else {
|
|
CredentialState::AccessDeny
|
|
};
|
|
|
|
// - stash the current state of all associated credentials
|
|
let primary = if matches!(primary_state, CredentialState::Modifiable) {
|
|
account.primary.clone()
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let passkeys = if matches!(passkeys_state, CredentialState::Modifiable) {
|
|
account.passkeys.clone()
|
|
} else {
|
|
BTreeMap::default()
|
|
};
|
|
|
|
let unixcred: Option<Credential> = if matches!(unixcred_state, CredentialState::Modifiable)
|
|
{
|
|
account.unix_extn().and_then(|uext| uext.ucred()).cloned()
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let sshkeys = if matches!(sshkeys_state, CredentialState::Modifiable) {
|
|
account.sshkeys().clone()
|
|
} else {
|
|
BTreeMap::default()
|
|
};
|
|
|
|
// Before we start, we pre-filter out anything that no longer conforms to policy.
|
|
// These would already be failing authentication, so they should have the appearance
|
|
// of "being removed".
|
|
let attested_passkeys = if matches!(attested_passkeys_state, CredentialState::Modifiable)
|
|
|| matches!(attested_passkeys_state, CredentialState::DeleteOnly)
|
|
{
|
|
if let Some(att_ca_list) = resolved_account_policy.webauthn_attestation_ca_list() {
|
|
let mut attested_passkeys = BTreeMap::default();
|
|
|
|
for (uuid, (label, apk)) in account.attested_passkeys.iter() {
|
|
match apk.verify_attestation(att_ca_list) {
|
|
Ok(_) => {
|
|
// Good to go
|
|
attested_passkeys.insert(*uuid, (label.clone(), apk.clone()));
|
|
}
|
|
Err(e) => {
|
|
warn!(eclass=?e, emsg=%e, "credential no longer meets attestation criteria");
|
|
}
|
|
}
|
|
}
|
|
|
|
attested_passkeys
|
|
} else {
|
|
// Seems weird here to be skipping filtering of the credentials. The reason is that
|
|
// if an account had registered attested passkeys in the past we can delete them, but
|
|
// not add new ones. Situation only occurs when policy isn't present on the account.
|
|
account.attested_passkeys.clone()
|
|
}
|
|
} else {
|
|
BTreeMap::default()
|
|
};
|
|
|
|
// Get the external credential portal, if any.
|
|
let ext_cred_portal = match (account.sync_parent_uuid, ext_cred_portal_can_view) {
|
|
(Some(sync_parent_uuid), true) => {
|
|
let sync_entry = self.qs_write.internal_search_uuid(sync_parent_uuid)?;
|
|
sync_entry
|
|
.get_ava_single_url(Attribute::SyncCredentialPortal)
|
|
.cloned()
|
|
.map(CUExtPortal::Some)
|
|
.unwrap_or(CUExtPortal::Hidden)
|
|
}
|
|
(Some(_), false) => CUExtPortal::Hidden,
|
|
(None, _) => CUExtPortal::None,
|
|
};
|
|
|
|
// Stash the issuer for some UI elements
|
|
let issuer = self.qs_write.get_domain_display_name().to_string();
|
|
|
|
// - store account policy (if present)
|
|
let session = CredentialUpdateSession {
|
|
account,
|
|
resolved_account_policy,
|
|
issuer,
|
|
intent_token_id,
|
|
ext_cred_portal,
|
|
primary,
|
|
primary_state,
|
|
unixcred,
|
|
unixcred_state,
|
|
sshkeys,
|
|
sshkeys_state,
|
|
passkeys,
|
|
passkeys_state,
|
|
attested_passkeys,
|
|
attested_passkeys_state,
|
|
mfaregstate: MfaRegState::None,
|
|
};
|
|
|
|
let max_ttl = ct + MAXIMUM_CRED_UPDATE_TTL;
|
|
|
|
let token = CredentialUpdateSessionTokenInner { sessionid, max_ttl };
|
|
|
|
let token_data = serde_json::to_vec(&token).map_err(|e| {
|
|
admin_error!(err = ?e, "Unable to encode token data");
|
|
OperationError::SerdeJsonError
|
|
})?;
|
|
|
|
let token_jwe = JweBuilder::from(token_data).build();
|
|
|
|
let token_enc = self
|
|
.qs_write
|
|
.get_domain_key_object_handle()?
|
|
.jwe_a128gcm_encrypt(&token_jwe, ct)?;
|
|
|
|
let status: CredentialUpdateSessionStatus = (&session).into();
|
|
|
|
let session = Arc::new(Mutex::new(session));
|
|
|
|
// Point of no return
|
|
|
|
// Sneaky! Now we know it will work, prune old sessions.
|
|
self.expire_credential_update_sessions(ct);
|
|
|
|
// Store the update session into the map.
|
|
self.cred_update_sessions.insert(sessionid, session);
|
|
trace!("cred_update_sessions.insert - {}", sessionid);
|
|
|
|
// - issue the CredentialUpdateToken (enc)
|
|
Ok((CredentialUpdateSessionToken { token_enc }, status))
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all)]
|
|
pub fn init_credential_update_intent(
|
|
&mut self,
|
|
event: &InitCredentialUpdateIntentEvent,
|
|
ct: Duration,
|
|
) -> Result<CredentialUpdateIntentToken, OperationError> {
|
|
let (account, _resolved_account_policy, perms) =
|
|
self.validate_init_credential_update(event.target, &event.ident)?;
|
|
|
|
// We should check in the acc-pol if we can proceed?
|
|
// Is there a reason account policy might deny us from proceeding?
|
|
|
|
// ==== AUTHORISATION CHECKED ===
|
|
|
|
// Build the intent token. Previously this was using 0 and then
|
|
// relying on clamp to raise this to 5 minutes, but that led to
|
|
// rapid timeouts that affected some users.
|
|
let mttl = event.max_ttl.unwrap_or(DEFAULT_INTENT_TTL);
|
|
let clamped_mttl = mttl.clamp(MINIMUM_INTENT_TTL, MAXIMUM_INTENT_TTL);
|
|
debug!(?clamped_mttl, "clamped update intent validity");
|
|
// Absolute expiry of the intent token in epoch seconds
|
|
let max_ttl = ct + clamped_mttl;
|
|
|
|
// Get the expiry of the intent token as an odt.
|
|
let expiry_time = OffsetDateTime::UNIX_EPOCH + max_ttl;
|
|
|
|
let intent_id = readable_password_from_random();
|
|
|
|
// Mark that we have created an intent token on the user.
|
|
// ⚠️ -- remember, there is a risk, very low, but still a risk of collision of the intent_id.
|
|
// instead of enforcing unique, which would divulge that the collision occurred, we
|
|
// write anyway, and instead on the intent access path we invalidate IF the collision
|
|
// occurs.
|
|
let mut modlist = ModifyList::new_append(
|
|
Attribute::CredentialUpdateIntentToken,
|
|
Value::IntentToken(
|
|
intent_id.clone(),
|
|
IntentTokenState::Valid { max_ttl, perms },
|
|
),
|
|
);
|
|
|
|
// Remove any old credential update intents
|
|
account
|
|
.credential_update_intent_tokens
|
|
.iter()
|
|
.for_each(|(existing_intent_id, state)| {
|
|
let max_ttl = match state {
|
|
IntentTokenState::Valid { max_ttl, perms: _ }
|
|
| IntentTokenState::InProgress {
|
|
max_ttl,
|
|
perms: _,
|
|
session_id: _,
|
|
session_ttl: _,
|
|
}
|
|
| IntentTokenState::Consumed { max_ttl } => *max_ttl,
|
|
};
|
|
|
|
if ct >= max_ttl {
|
|
modlist.push_mod(Modify::Removed(
|
|
Attribute::CredentialUpdateIntentToken,
|
|
PartialValue::IntentToken(existing_intent_id.clone()),
|
|
));
|
|
}
|
|
});
|
|
|
|
self.qs_write
|
|
.internal_modify(
|
|
// Filter as executed
|
|
&filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(account.uuid))),
|
|
&modlist,
|
|
)
|
|
.map_err(|e| {
|
|
request_error!(error = ?e);
|
|
e
|
|
})?;
|
|
|
|
Ok(CredentialUpdateIntentToken {
|
|
intent_id,
|
|
expiry_time,
|
|
})
|
|
}
|
|
|
|
pub fn exchange_intent_credential_update(
|
|
&mut self,
|
|
token: CredentialUpdateIntentTokenExchange,
|
|
current_time: Duration,
|
|
) -> Result<(CredentialUpdateSessionToken, CredentialUpdateSessionStatus), OperationError> {
|
|
let CredentialUpdateIntentTokenExchange { intent_id } = token;
|
|
|
|
/*
|
|
let entry = self.qs_write.internal_search_uuid(&token.target)?;
|
|
*/
|
|
// ⚠️ due to a low, but possible risk of intent_id collision, if there are multiple
|
|
// entries, we will reject the intent.
|
|
// DO we need to force both to "Consumed" in this step?
|
|
//
|
|
// ⚠️ If not present, it may be due to replication delay. We can report this.
|
|
|
|
let mut vs = self.qs_write.internal_search(filter!(f_eq(
|
|
Attribute::CredentialUpdateIntentToken,
|
|
PartialValue::IntentToken(intent_id.clone())
|
|
)))?;
|
|
|
|
let entry = match vs.pop() {
|
|
Some(entry) => {
|
|
if vs.is_empty() {
|
|
// Happy Path!
|
|
entry
|
|
} else {
|
|
// Multiple entries matched! This is bad!
|
|
let matched_uuids = std::iter::once(entry.get_uuid())
|
|
.chain(vs.iter().map(|e| e.get_uuid()))
|
|
.collect::<Vec<_>>();
|
|
|
|
security_error!("Multiple entries had identical intent_id - for safety, rejecting the use of this intent_id! {:?}", matched_uuids);
|
|
|
|
/*
|
|
let mut modlist = ModifyList::new();
|
|
|
|
modlist.push_mod(Modify::Removed(
|
|
Attribute::CredentialUpdateIntentToken.into(),
|
|
PartialValue::IntentToken(intent_id.clone()),
|
|
));
|
|
|
|
let filter_or = matched_uuids.into_iter()
|
|
.map(|u| f_eq(Attribute::Uuid, PartialValue::new_uuid(u)))
|
|
.collect();
|
|
|
|
self.qs_write
|
|
.internal_modify(
|
|
// Filter as executed
|
|
&filter!(f_or(filter_or)),
|
|
&modlist,
|
|
)
|
|
.map_err(|e| {
|
|
request_error!(error = ?e);
|
|
e
|
|
})?;
|
|
*/
|
|
|
|
return Err(OperationError::InvalidState);
|
|
}
|
|
}
|
|
None => {
|
|
security_info!(
|
|
"Rejecting Update Session - Intent Token does not exist (replication delay?)",
|
|
);
|
|
return Err(OperationError::Wait(
|
|
OffsetDateTime::UNIX_EPOCH + (current_time + Duration::from_secs(150)),
|
|
));
|
|
}
|
|
};
|
|
|
|
// Is target an account? This checks for us.
|
|
let (account, resolved_account_policy) =
|
|
Account::try_from_entry_with_policy(entry.as_ref(), &mut self.qs_write)?;
|
|
|
|
// Check there is not already a user session in progress with this intent token.
|
|
// Is there a need to revoke intent tokens?
|
|
|
|
let (max_ttl, perms) = match account.credential_update_intent_tokens.get(&intent_id) {
|
|
Some(IntentTokenState::Consumed { max_ttl: _ }) => {
|
|
security_info!(
|
|
%entry,
|
|
%account.uuid,
|
|
"Rejecting Update Session - Intent Token has already been exchanged",
|
|
);
|
|
return Err(OperationError::SessionExpired);
|
|
}
|
|
Some(IntentTokenState::InProgress {
|
|
max_ttl,
|
|
perms,
|
|
session_id,
|
|
session_ttl,
|
|
}) => {
|
|
if current_time > *session_ttl {
|
|
// The former session has expired, continue.
|
|
security_info!(
|
|
%entry,
|
|
%account.uuid,
|
|
"Initiating Credential Update Session - Previous session {} has expired", session_id
|
|
);
|
|
} else {
|
|
// The former session has been orphaned while in use. This can be from someone
|
|
// ctrl-c during their use of the session or refreshing the page without committing.
|
|
//
|
|
// we don't try to exclusive lock the token here with the current time as we previously
|
|
// did. This is because with async replication, there isn't a guarantee this will actually
|
|
// be sent to another server "soon enough" to prevent abuse on the separate server. So
|
|
// all this "lock" actually does is annoy legitimate users and not stop abuse. We
|
|
// STILL keep the InProgress state though since we check it on commit, so this
|
|
// forces the previous orphan session to be immediately invalidated!
|
|
security_info!(
|
|
%entry,
|
|
%account.uuid,
|
|
"Initiating Update Session - Intent Token was in use {} - this will be invalidated.", session_id
|
|
);
|
|
};
|
|
(*max_ttl, *perms)
|
|
}
|
|
Some(IntentTokenState::Valid { max_ttl, perms }) => (*max_ttl, *perms),
|
|
None => {
|
|
admin_error!("Corruption may have occurred - index yielded an entry for intent_id, but the entry does not contain that intent_id");
|
|
return Err(OperationError::InvalidState);
|
|
}
|
|
};
|
|
|
|
if current_time >= max_ttl {
|
|
security_info!(?current_time, ?max_ttl, %account.uuid, "intent has expired");
|
|
return Err(OperationError::SessionExpired);
|
|
}
|
|
|
|
security_info!(
|
|
%entry,
|
|
%account.uuid,
|
|
"Initiating Credential Update Session",
|
|
);
|
|
|
|
// To prevent issues with repl, we need to associate this cred update session id, with
|
|
// this intent token id.
|
|
|
|
// Store the intent id in the session (if needed) so that we can check the state at the
|
|
// end of the update.
|
|
|
|
// We need to pin the id from the intent token into the credential to ensure it's not reused
|
|
|
|
// Need to change this to the expiry time, so we can purge up to.
|
|
let session_id = uuid_from_duration(current_time + MAXIMUM_CRED_UPDATE_TTL, self.sid);
|
|
|
|
let mut modlist = ModifyList::new();
|
|
|
|
modlist.push_mod(Modify::Removed(
|
|
Attribute::CredentialUpdateIntentToken,
|
|
PartialValue::IntentToken(intent_id.clone()),
|
|
));
|
|
modlist.push_mod(Modify::Present(
|
|
Attribute::CredentialUpdateIntentToken,
|
|
Value::IntentToken(
|
|
intent_id.clone(),
|
|
IntentTokenState::InProgress {
|
|
max_ttl,
|
|
perms,
|
|
session_id,
|
|
session_ttl: current_time + MAXIMUM_CRED_UPDATE_TTL,
|
|
},
|
|
),
|
|
));
|
|
|
|
self.qs_write
|
|
.internal_modify(
|
|
// Filter as executed
|
|
&filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(account.uuid))),
|
|
&modlist,
|
|
)
|
|
.map_err(|e| {
|
|
request_error!(error = ?e);
|
|
e
|
|
})?;
|
|
|
|
// ==========
|
|
// Okay, good to exchange.
|
|
|
|
self.create_credupdate_session(
|
|
session_id,
|
|
Some(intent_id),
|
|
account,
|
|
resolved_account_policy,
|
|
perms,
|
|
current_time,
|
|
)
|
|
}
|
|
|
|
#[instrument(level = "debug", skip_all)]
|
|
pub fn init_credential_update(
|
|
&mut self,
|
|
event: &InitCredentialUpdateEvent,
|
|
current_time: Duration,
|
|
) -> Result<(CredentialUpdateSessionToken, CredentialUpdateSessionStatus), OperationError> {
|
|
let (account, resolved_account_policy, perms) =
|
|
self.validate_init_credential_update(event.target, &event.ident)?;
|
|
|
|
// ==== AUTHORISATION CHECKED ===
|
|
// This is the expiry time, so that our cleanup task can "purge up to now" rather
|
|
// than needing to do calculations.
|
|
let sessionid = uuid_from_duration(current_time + MAXIMUM_CRED_UPDATE_TTL, self.sid);
|
|
|
|
// Build the cred update session.
|
|
self.create_credupdate_session(
|
|
sessionid,
|
|
None,
|
|
account,
|
|
resolved_account_policy,
|
|
perms,
|
|
current_time,
|
|
)
|
|
}
|
|
|
|
#[instrument(level = "trace", skip(self))]
|
|
pub fn expire_credential_update_sessions(&mut self, ct: Duration) {
|
|
let before = self.cred_update_sessions.len();
|
|
let split_at = uuid_from_duration(ct, self.sid);
|
|
trace!(?split_at, "expiring less than");
|
|
self.cred_update_sessions.split_off_lt(&split_at);
|
|
let removed = before - self.cred_update_sessions.len();
|
|
trace!(?removed);
|
|
}
|
|
|
|
// This shares some common paths between commit and cancel.
|
|
fn credential_update_commit_common(
|
|
&mut self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
) -> Result<
|
|
(
|
|
ModifyList<ModifyInvalid>,
|
|
CredentialUpdateSession,
|
|
CredentialUpdateSessionTokenInner,
|
|
),
|
|
OperationError,
|
|
> {
|
|
let session_token: CredentialUpdateSessionTokenInner = self
|
|
.qs_write
|
|
.get_domain_key_object_handle()?
|
|
.jwe_decrypt(&cust.token_enc)
|
|
.map_err(|e| {
|
|
admin_error!(?e, "Failed to decrypt credential update session request");
|
|
OperationError::SessionExpired
|
|
})
|
|
.and_then(|data| {
|
|
data.from_json().map_err(|e| {
|
|
admin_error!(err = ?e, "Failed to deserialise credential update session request");
|
|
OperationError::SerdeJsonError
|
|
})
|
|
})?;
|
|
|
|
if ct >= session_token.max_ttl {
|
|
trace!(?ct, ?session_token.max_ttl);
|
|
security_info!(%session_token.sessionid, "session expired");
|
|
return Err(OperationError::SessionExpired);
|
|
}
|
|
|
|
let session_handle = self.cred_update_sessions.remove(&session_token.sessionid)
|
|
.ok_or_else(|| {
|
|
admin_error!("No such sessionid exists on this server - may be due to a load balancer failover or replay? {:?}", session_token.sessionid);
|
|
OperationError::InvalidState
|
|
})?;
|
|
|
|
let session = session_handle
|
|
.try_lock()
|
|
.map(|guard| (*guard).clone())
|
|
.map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
|
|
trace!(?session);
|
|
|
|
let modlist = ModifyList::new();
|
|
|
|
Ok((modlist, session, session_token))
|
|
}
|
|
|
|
pub fn commit_credential_update(
|
|
&mut self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
) -> Result<(), OperationError> {
|
|
let (mut modlist, session, session_token) =
|
|
self.credential_update_commit_common(cust, ct)?;
|
|
|
|
// Can we actually proceed?
|
|
let can_commit = session.can_commit();
|
|
if !can_commit.0 {
|
|
let commit_failure_reasons = can_commit
|
|
.1
|
|
.iter()
|
|
.map(|e| e.to_string())
|
|
.collect::<Vec<String>>()
|
|
.join(", ");
|
|
admin_error!(
|
|
"Session is unable to commit due to: {}",
|
|
commit_failure_reasons
|
|
);
|
|
return Err(OperationError::CU0004SessionInconsistent);
|
|
}
|
|
|
|
// Setup mods for the various bits. We always assert an *exact* state.
|
|
|
|
// IF an intent was used on this session, AND that intent is not in our
|
|
// session state as an exact match, FAIL the commit. Move the intent to "Consumed".
|
|
//
|
|
// Should we mark the credential as suspect (lock the account?)
|
|
//
|
|
// If the credential has changed, reject? Do we need "asserts" in the modlist?
|
|
// that would allow better expression of this, and will allow resolving via replication
|
|
|
|
// If an intent token was used, remove it's former value, and add it as consumed.
|
|
if let Some(intent_token_id) = &session.intent_token_id {
|
|
let entry = self.qs_write.internal_search_uuid(session.account.uuid)?;
|
|
let account = Account::try_from_entry_rw(entry.as_ref(), &mut self.qs_write)?;
|
|
|
|
let max_ttl = match account.credential_update_intent_tokens.get(intent_token_id) {
|
|
Some(IntentTokenState::InProgress {
|
|
max_ttl,
|
|
perms: _,
|
|
session_id,
|
|
session_ttl: _,
|
|
}) => {
|
|
if *session_id != session_token.sessionid {
|
|
security_info!("Session originated from an intent token, but the intent token has initiated a conflicting second update session. Refusing to commit changes.");
|
|
return Err(OperationError::CU0005IntentTokenConflict);
|
|
} else {
|
|
*max_ttl
|
|
}
|
|
}
|
|
Some(IntentTokenState::Consumed { max_ttl: _ })
|
|
| Some(IntentTokenState::Valid {
|
|
max_ttl: _,
|
|
perms: _,
|
|
})
|
|
| None => {
|
|
security_info!("Session originated from an intent token, but the intent token has transitioned to an invalid state. Refusing to commit changes.");
|
|
return Err(OperationError::CU0006IntentTokenInvalidated);
|
|
}
|
|
};
|
|
|
|
modlist.push_mod(Modify::Removed(
|
|
Attribute::CredentialUpdateIntentToken,
|
|
PartialValue::IntentToken(intent_token_id.clone()),
|
|
));
|
|
modlist.push_mod(Modify::Present(
|
|
Attribute::CredentialUpdateIntentToken,
|
|
Value::IntentToken(
|
|
intent_token_id.clone(),
|
|
IntentTokenState::Consumed { max_ttl },
|
|
),
|
|
));
|
|
};
|
|
|
|
match session.primary_state {
|
|
CredentialState::Modifiable => {
|
|
modlist.push_mod(Modify::Purged(Attribute::PrimaryCredential));
|
|
if let Some(ncred) = &session.primary {
|
|
let vcred = Value::new_credential("primary", ncred.clone());
|
|
modlist.push_mod(Modify::Present(Attribute::PrimaryCredential, vcred));
|
|
};
|
|
}
|
|
CredentialState::DeleteOnly | CredentialState::PolicyDeny => {
|
|
modlist.push_mod(Modify::Purged(Attribute::PrimaryCredential));
|
|
}
|
|
CredentialState::AccessDeny => {}
|
|
};
|
|
|
|
match session.passkeys_state {
|
|
CredentialState::DeleteOnly | CredentialState::Modifiable => {
|
|
modlist.push_mod(Modify::Purged(Attribute::PassKeys));
|
|
// Add all the passkeys. If none, nothing will be added! This handles
|
|
// the delete case quite cleanly :)
|
|
session.passkeys.iter().for_each(|(uuid, (tag, pk))| {
|
|
let v_pk = Value::Passkey(*uuid, tag.clone(), pk.clone());
|
|
modlist.push_mod(Modify::Present(Attribute::PassKeys, v_pk));
|
|
});
|
|
}
|
|
CredentialState::PolicyDeny => {
|
|
modlist.push_mod(Modify::Purged(Attribute::PassKeys));
|
|
}
|
|
CredentialState::AccessDeny => {}
|
|
};
|
|
|
|
match session.attested_passkeys_state {
|
|
CredentialState::DeleteOnly | CredentialState::Modifiable => {
|
|
modlist.push_mod(Modify::Purged(Attribute::AttestedPasskeys));
|
|
// Add all the passkeys. If none, nothing will be added! This handles
|
|
// the delete case quite cleanly :)
|
|
session
|
|
.attested_passkeys
|
|
.iter()
|
|
.for_each(|(uuid, (tag, pk))| {
|
|
let v_pk = Value::AttestedPasskey(*uuid, tag.clone(), pk.clone());
|
|
modlist.push_mod(Modify::Present(Attribute::AttestedPasskeys, v_pk));
|
|
});
|
|
}
|
|
CredentialState::PolicyDeny => {
|
|
modlist.push_mod(Modify::Purged(Attribute::AttestedPasskeys));
|
|
}
|
|
// CredentialState::Disabled |
|
|
CredentialState::AccessDeny => {}
|
|
};
|
|
|
|
match session.unixcred_state {
|
|
CredentialState::DeleteOnly | CredentialState::Modifiable => {
|
|
modlist.push_mod(Modify::Purged(Attribute::UnixPassword));
|
|
if let Some(ncred) = &session.unixcred {
|
|
let vcred = Value::new_credential("unix", ncred.clone());
|
|
modlist.push_mod(Modify::Present(Attribute::UnixPassword, vcred));
|
|
}
|
|
}
|
|
CredentialState::PolicyDeny => {
|
|
modlist.push_mod(Modify::Purged(Attribute::UnixPassword));
|
|
}
|
|
CredentialState::AccessDeny => {}
|
|
};
|
|
|
|
match session.sshkeys_state {
|
|
CredentialState::DeleteOnly | CredentialState::Modifiable => {
|
|
modlist.push_mod(Modify::Purged(Attribute::SshPublicKey));
|
|
for (tag, pk) in &session.sshkeys {
|
|
let v_sk = Value::SshKey(tag.clone(), pk.clone());
|
|
modlist.push_mod(Modify::Present(Attribute::SshPublicKey, v_sk));
|
|
}
|
|
}
|
|
CredentialState::PolicyDeny => {
|
|
modlist.push_mod(Modify::Purged(Attribute::SshPublicKey));
|
|
}
|
|
CredentialState::AccessDeny => {}
|
|
};
|
|
|
|
// Apply to the account!
|
|
trace!(?modlist, "processing change");
|
|
|
|
if modlist.is_empty() {
|
|
trace!("no changes to apply");
|
|
Ok(())
|
|
} else {
|
|
self.qs_write
|
|
.internal_modify(
|
|
// Filter as executed
|
|
&filter!(f_eq(
|
|
Attribute::Uuid,
|
|
PartialValue::Uuid(session.account.uuid)
|
|
)),
|
|
&modlist,
|
|
)
|
|
.map_err(|e| {
|
|
request_error!(error = ?e);
|
|
e
|
|
})
|
|
}
|
|
}
|
|
|
|
pub fn cancel_credential_update(
|
|
&mut self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
) -> Result<(), OperationError> {
|
|
let (mut modlist, session, session_token) =
|
|
self.credential_update_commit_common(cust, ct)?;
|
|
|
|
// If an intent token was used, remove it's former value, and add it as VALID since we didn't commit.
|
|
if let Some(intent_token_id) = &session.intent_token_id {
|
|
let entry = self.qs_write.internal_search_uuid(session.account.uuid)?;
|
|
let account = Account::try_from_entry_rw(entry.as_ref(), &mut self.qs_write)?;
|
|
|
|
let (max_ttl, perms) = match account
|
|
.credential_update_intent_tokens
|
|
.get(intent_token_id)
|
|
{
|
|
Some(IntentTokenState::InProgress {
|
|
max_ttl,
|
|
perms,
|
|
session_id,
|
|
session_ttl: _,
|
|
}) => {
|
|
if *session_id != session_token.sessionid {
|
|
security_info!("Session originated from an intent token, but the intent token has initiated a conflicting second update session. Refusing to commit changes.");
|
|
return Err(OperationError::InvalidState);
|
|
} else {
|
|
(*max_ttl, *perms)
|
|
}
|
|
}
|
|
Some(IntentTokenState::Consumed { max_ttl: _ })
|
|
| Some(IntentTokenState::Valid {
|
|
max_ttl: _,
|
|
perms: _,
|
|
})
|
|
| None => {
|
|
security_info!("Session originated from an intent token, but the intent token has transitioned to an invalid state. Refusing to commit changes.");
|
|
return Err(OperationError::InvalidState);
|
|
}
|
|
};
|
|
|
|
modlist.push_mod(Modify::Removed(
|
|
Attribute::CredentialUpdateIntentToken,
|
|
PartialValue::IntentToken(intent_token_id.clone()),
|
|
));
|
|
modlist.push_mod(Modify::Present(
|
|
Attribute::CredentialUpdateIntentToken,
|
|
Value::IntentToken(
|
|
intent_token_id.clone(),
|
|
IntentTokenState::Valid { max_ttl, perms },
|
|
),
|
|
));
|
|
};
|
|
|
|
// Apply to the account!
|
|
if !modlist.is_empty() {
|
|
trace!(?modlist, "processing change");
|
|
|
|
self.qs_write
|
|
.internal_modify(
|
|
// Filter as executed
|
|
&filter!(f_eq(
|
|
Attribute::Uuid,
|
|
PartialValue::Uuid(session.account.uuid)
|
|
)),
|
|
&modlist,
|
|
)
|
|
.map_err(|e| {
|
|
request_error!(error = ?e);
|
|
e
|
|
})
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
impl IdmServerCredUpdateTransaction<'_> {
|
|
#[cfg(test)]
|
|
pub fn get_origin(&self) -> &Url {
|
|
&self.webauthn.get_allowed_origins()[0]
|
|
}
|
|
|
|
fn get_current_session(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
) -> Result<CredentialUpdateSessionMutex, OperationError> {
|
|
let session_token: CredentialUpdateSessionTokenInner = self
|
|
.qs_read
|
|
.get_domain_key_object_handle()?
|
|
.jwe_decrypt(&cust.token_enc)
|
|
.map_err(|e| {
|
|
admin_error!(?e, "Failed to decrypt credential update session request");
|
|
OperationError::SessionExpired
|
|
})
|
|
.and_then(|data| {
|
|
data.from_json().map_err(|e| {
|
|
admin_error!(err = ?e, "Failed to deserialise credential update session request");
|
|
OperationError::SerdeJsonError
|
|
})
|
|
})?;
|
|
|
|
// Check the TTL
|
|
if ct >= session_token.max_ttl {
|
|
trace!(?ct, ?session_token.max_ttl);
|
|
security_info!(%session_token.sessionid, "session expired");
|
|
return Err(OperationError::SessionExpired);
|
|
}
|
|
|
|
self.cred_update_sessions.get(&session_token.sessionid)
|
|
.ok_or_else(|| {
|
|
admin_error!("No such sessionid exists on this server - may be due to a load balancer failover or token replay? {}", session_token.sessionid);
|
|
OperationError::InvalidState
|
|
})
|
|
.cloned()
|
|
}
|
|
|
|
// I think I need this to be a try lock instead, and fail on error, because
|
|
// of the nature of the async bits.
|
|
pub fn credential_update_status(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
let status: CredentialUpdateSessionStatus = session.deref().into();
|
|
Ok(status)
|
|
}
|
|
|
|
#[instrument(level = "trace", skip(self))]
|
|
fn check_password_quality(
|
|
&self,
|
|
cleartext: &str,
|
|
resolved_account_policy: &ResolvedAccountPolicy,
|
|
related_inputs: &[&str],
|
|
radius_secret: Option<&str>,
|
|
) -> Result<(), PasswordQuality> {
|
|
// password strength and badlisting is always global, rather than per-pw-policy.
|
|
// pw-policy as check on the account is about requirements for mfa for example.
|
|
|
|
// is the password at least 10 char?
|
|
let pw_min_length = resolved_account_policy.pw_min_length();
|
|
if cleartext.len() < pw_min_length as usize {
|
|
return Err(PasswordQuality::TooShort(pw_min_length));
|
|
}
|
|
|
|
if let Some(some_radius_secret) = radius_secret {
|
|
if cleartext.contains(some_radius_secret) {
|
|
return Err(PasswordQuality::DontReusePasswords);
|
|
}
|
|
}
|
|
|
|
// zxcvbn doesn't appear to be picking these up?
|
|
for related in related_inputs {
|
|
if cleartext.contains(related) {
|
|
return Err(PasswordQuality::Feedback(vec![
|
|
PasswordFeedback::NamesAndSurnamesByThemselvesAreEasyToGuess,
|
|
PasswordFeedback::AvoidDatesAndYearsThatAreAssociatedWithYou,
|
|
]));
|
|
}
|
|
}
|
|
|
|
// does the password pass zxcvbn?
|
|
let entropy = zxcvbn(cleartext, related_inputs);
|
|
|
|
// PW's should always be enforced as strong as possible.
|
|
if entropy.score() < Score::Four {
|
|
// The password is too week as per:
|
|
// https://docs.rs/zxcvbn/2.0.0/zxcvbn/struct.Entropy.html
|
|
let feedback: zxcvbn::feedback::Feedback = entropy
|
|
.feedback()
|
|
.ok_or(OperationError::InvalidState)
|
|
.cloned()
|
|
.map_err(|e| {
|
|
security_info!("zxcvbn returned no feedback when score < 3 -> {:?}", e);
|
|
// Return some generic feedback when the password is this bad.
|
|
PasswordQuality::Feedback(vec![
|
|
PasswordFeedback::UseAFewWordsAvoidCommonPhrases,
|
|
PasswordFeedback::AddAnotherWordOrTwo,
|
|
PasswordFeedback::NoNeedForSymbolsDigitsOrUppercaseLetters,
|
|
])
|
|
})?;
|
|
|
|
security_info!(?feedback, "pw quality feedback");
|
|
|
|
let feedback: Vec<_> = feedback
|
|
.suggestions()
|
|
.iter()
|
|
.map(|s| {
|
|
match s {
|
|
zxcvbn::feedback::Suggestion::UseAFewWordsAvoidCommonPhrases => {
|
|
PasswordFeedback::UseAFewWordsAvoidCommonPhrases
|
|
}
|
|
zxcvbn::feedback::Suggestion::NoNeedForSymbolsDigitsOrUppercaseLetters => {
|
|
PasswordFeedback::NoNeedForSymbolsDigitsOrUppercaseLetters
|
|
}
|
|
zxcvbn::feedback::Suggestion::AddAnotherWordOrTwo => {
|
|
PasswordFeedback::AddAnotherWordOrTwo
|
|
}
|
|
zxcvbn::feedback::Suggestion::CapitalizationDoesntHelpVeryMuch => {
|
|
PasswordFeedback::CapitalizationDoesntHelpVeryMuch
|
|
}
|
|
zxcvbn::feedback::Suggestion::AllUppercaseIsAlmostAsEasyToGuessAsAllLowercase => {
|
|
PasswordFeedback::AllUppercaseIsAlmostAsEasyToGuessAsAllLowercase
|
|
}
|
|
zxcvbn::feedback::Suggestion::ReversedWordsArentMuchHarderToGuess => {
|
|
PasswordFeedback::ReversedWordsArentMuchHarderToGuess
|
|
}
|
|
zxcvbn::feedback::Suggestion::PredictableSubstitutionsDontHelpVeryMuch => {
|
|
PasswordFeedback::PredictableSubstitutionsDontHelpVeryMuch
|
|
}
|
|
zxcvbn::feedback::Suggestion::UseALongerKeyboardPatternWithMoreTurns => {
|
|
PasswordFeedback::UseALongerKeyboardPatternWithMoreTurns
|
|
}
|
|
zxcvbn::feedback::Suggestion::AvoidRepeatedWordsAndCharacters => {
|
|
PasswordFeedback::AvoidRepeatedWordsAndCharacters
|
|
}
|
|
zxcvbn::feedback::Suggestion::AvoidSequences => {
|
|
PasswordFeedback::AvoidSequences
|
|
}
|
|
zxcvbn::feedback::Suggestion::AvoidRecentYears => {
|
|
PasswordFeedback::AvoidRecentYears
|
|
}
|
|
zxcvbn::feedback::Suggestion::AvoidYearsThatAreAssociatedWithYou => {
|
|
PasswordFeedback::AvoidYearsThatAreAssociatedWithYou
|
|
}
|
|
zxcvbn::feedback::Suggestion::AvoidDatesAndYearsThatAreAssociatedWithYou => {
|
|
PasswordFeedback::AvoidDatesAndYearsThatAreAssociatedWithYou
|
|
}
|
|
}
|
|
})
|
|
.chain(feedback.warning().map(|w| match w {
|
|
zxcvbn::feedback::Warning::StraightRowsOfKeysAreEasyToGuess => {
|
|
PasswordFeedback::StraightRowsOfKeysAreEasyToGuess
|
|
}
|
|
zxcvbn::feedback::Warning::ShortKeyboardPatternsAreEasyToGuess => {
|
|
PasswordFeedback::ShortKeyboardPatternsAreEasyToGuess
|
|
}
|
|
zxcvbn::feedback::Warning::RepeatsLikeAaaAreEasyToGuess => {
|
|
PasswordFeedback::RepeatsLikeAaaAreEasyToGuess
|
|
}
|
|
zxcvbn::feedback::Warning::RepeatsLikeAbcAbcAreOnlySlightlyHarderToGuess => {
|
|
PasswordFeedback::RepeatsLikeAbcAbcAreOnlySlightlyHarderToGuess
|
|
}
|
|
zxcvbn::feedback::Warning::ThisIsATop10Password => {
|
|
PasswordFeedback::ThisIsATop10Password
|
|
}
|
|
zxcvbn::feedback::Warning::ThisIsATop100Password => {
|
|
PasswordFeedback::ThisIsATop100Password
|
|
}
|
|
zxcvbn::feedback::Warning::ThisIsACommonPassword => {
|
|
PasswordFeedback::ThisIsACommonPassword
|
|
}
|
|
zxcvbn::feedback::Warning::ThisIsSimilarToACommonlyUsedPassword => {
|
|
PasswordFeedback::ThisIsSimilarToACommonlyUsedPassword
|
|
}
|
|
zxcvbn::feedback::Warning::SequencesLikeAbcAreEasyToGuess => {
|
|
PasswordFeedback::SequencesLikeAbcAreEasyToGuess
|
|
}
|
|
zxcvbn::feedback::Warning::RecentYearsAreEasyToGuess => {
|
|
PasswordFeedback::RecentYearsAreEasyToGuess
|
|
}
|
|
zxcvbn::feedback::Warning::AWordByItselfIsEasyToGuess => {
|
|
PasswordFeedback::AWordByItselfIsEasyToGuess
|
|
}
|
|
zxcvbn::feedback::Warning::DatesAreOftenEasyToGuess => {
|
|
PasswordFeedback::DatesAreOftenEasyToGuess
|
|
}
|
|
zxcvbn::feedback::Warning::NamesAndSurnamesByThemselvesAreEasyToGuess => {
|
|
PasswordFeedback::NamesAndSurnamesByThemselvesAreEasyToGuess
|
|
}
|
|
zxcvbn::feedback::Warning::CommonNamesAndSurnamesAreEasyToGuess => {
|
|
PasswordFeedback::CommonNamesAndSurnamesAreEasyToGuess
|
|
}
|
|
}))
|
|
.collect();
|
|
|
|
return Err(PasswordQuality::Feedback(feedback));
|
|
}
|
|
|
|
// check a password badlist to eliminate more content
|
|
// we check the password as "lower case" to help eliminate possibilities
|
|
// also, when pw_badlist_cache is read from DB, it is read as Value (iutf8 lowercase)
|
|
if self
|
|
.qs_read
|
|
.pw_badlist()
|
|
.contains(&cleartext.to_lowercase())
|
|
{
|
|
security_info!("Password found in badlist, rejecting");
|
|
Err(PasswordQuality::BadListed)
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[instrument(level = "trace", skip(cust, self))]
|
|
pub fn credential_primary_set_password(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
pw: &str,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !matches!(session.primary_state, CredentialState::Modifiable) {
|
|
error!("Session does not have permission to modify primary credential");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
self.check_password_quality(
|
|
pw,
|
|
&session.resolved_account_policy,
|
|
session.account.related_inputs().as_slice(),
|
|
session.account.radius_secret.as_deref(),
|
|
)
|
|
.map_err(|e| match e {
|
|
PasswordQuality::TooShort(sz) => {
|
|
OperationError::PasswordQuality(vec![PasswordFeedback::TooShort(sz)])
|
|
}
|
|
PasswordQuality::BadListed => {
|
|
OperationError::PasswordQuality(vec![PasswordFeedback::BadListed])
|
|
}
|
|
PasswordQuality::DontReusePasswords => {
|
|
OperationError::PasswordQuality(vec![PasswordFeedback::DontReusePasswords])
|
|
}
|
|
PasswordQuality::Feedback(feedback) => OperationError::PasswordQuality(feedback),
|
|
})?;
|
|
|
|
let ncred = match &session.primary {
|
|
Some(primary) => {
|
|
// Is there a need to update the uuid of the cred re softlocks?
|
|
primary.set_password(self.crypto_policy, pw)?
|
|
}
|
|
None => Credential::new_password_only(self.crypto_policy, pw)?,
|
|
};
|
|
|
|
session.primary = Some(ncred);
|
|
Ok(session.deref().into())
|
|
}
|
|
|
|
pub fn credential_primary_init_totp(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !matches!(session.primary_state, CredentialState::Modifiable) {
|
|
error!("Session does not have permission to modify primary credential");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
// Is there something else in progress? Cancel it if so.
|
|
if !matches!(session.mfaregstate, MfaRegState::None) {
|
|
debug!("Clearing incomplete mfareg");
|
|
}
|
|
|
|
// Generate the TOTP.
|
|
let totp_token = Totp::generate_secure(TOTP_DEFAULT_STEP);
|
|
|
|
session.mfaregstate = MfaRegState::TotpInit(totp_token);
|
|
// Now that it's in the state, it'll be in the status when returned.
|
|
Ok(session.deref().into())
|
|
}
|
|
|
|
pub fn credential_primary_check_totp(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
totp_chal: u32,
|
|
label: &str,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !matches!(session.primary_state, CredentialState::Modifiable) {
|
|
error!("Session does not have permission to modify primary credential");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
// Are we in a totp reg state?
|
|
match &session.mfaregstate {
|
|
MfaRegState::TotpInit(totp_token)
|
|
| MfaRegState::TotpTryAgain(totp_token)
|
|
| MfaRegState::TotpNameTryAgain(totp_token, _)
|
|
| MfaRegState::TotpInvalidSha1(totp_token, _, _) => {
|
|
if session
|
|
.primary
|
|
.as_ref()
|
|
.map(|cred| cred.has_totp_by_name(label))
|
|
.unwrap_or_default()
|
|
|| label.trim().is_empty()
|
|
|| !Value::validate_str_escapes(label)
|
|
{
|
|
// The user is trying to add a second TOTP under the same name. Lets save them from themselves
|
|
session.mfaregstate =
|
|
MfaRegState::TotpNameTryAgain(totp_token.clone(), label.into());
|
|
return Ok(session.deref().into());
|
|
}
|
|
|
|
if totp_token.verify(totp_chal, ct) {
|
|
// It was valid. Update the credential.
|
|
let ncred = session
|
|
.primary
|
|
.as_ref()
|
|
.map(|cred| cred.append_totp(label.to_string(), totp_token.clone()))
|
|
.ok_or_else(|| {
|
|
admin_error!("A TOTP was added, but no primary credential stub exists");
|
|
OperationError::InvalidState
|
|
})?;
|
|
|
|
session.primary = Some(ncred);
|
|
|
|
// Set the state to None.
|
|
session.mfaregstate = MfaRegState::None;
|
|
Ok(session.deref().into())
|
|
} else {
|
|
// What if it's a broken authenticator app? Google authenticator
|
|
// and Authy both force SHA1 and ignore the algo we send. So let's
|
|
// check that just in case.
|
|
let token_sha1 = totp_token.clone().downgrade_to_legacy();
|
|
|
|
if token_sha1.verify(totp_chal, ct) {
|
|
// Greeeaaaaaatttt. It's a broken app. Let's check the user
|
|
// knows this is broken, before we proceed.
|
|
session.mfaregstate = MfaRegState::TotpInvalidSha1(
|
|
totp_token.clone(),
|
|
token_sha1,
|
|
label.to_string(),
|
|
);
|
|
Ok(session.deref().into())
|
|
} else {
|
|
// Let them check again, it's a typo.
|
|
session.mfaregstate = MfaRegState::TotpTryAgain(totp_token.clone());
|
|
Ok(session.deref().into())
|
|
}
|
|
}
|
|
}
|
|
_ => Err(OperationError::InvalidRequestState),
|
|
}
|
|
}
|
|
|
|
pub fn credential_primary_accept_sha1_totp(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !matches!(session.primary_state, CredentialState::Modifiable) {
|
|
error!("Session does not have permission to modify primary credential");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
// Are we in a totp reg state?
|
|
match &session.mfaregstate {
|
|
MfaRegState::TotpInvalidSha1(_, token_sha1, label) => {
|
|
// They have accepted it as sha1
|
|
let ncred = session
|
|
.primary
|
|
.as_ref()
|
|
.map(|cred| cred.append_totp(label.to_string(), token_sha1.clone()))
|
|
.ok_or_else(|| {
|
|
admin_error!("A TOTP was added, but no primary credential stub exists");
|
|
OperationError::InvalidState
|
|
})?;
|
|
|
|
security_info!("A SHA1 TOTP credential was accepted");
|
|
|
|
session.primary = Some(ncred);
|
|
|
|
// Set the state to None.
|
|
session.mfaregstate = MfaRegState::None;
|
|
Ok(session.deref().into())
|
|
}
|
|
_ => Err(OperationError::InvalidRequestState),
|
|
}
|
|
}
|
|
|
|
pub fn credential_primary_remove_totp(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
label: &str,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !matches!(session.primary_state, CredentialState::Modifiable) {
|
|
error!("Session does not have permission to modify primary credential");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
if !matches!(session.mfaregstate, MfaRegState::None) {
|
|
admin_info!("Invalid TOTP state, another update is in progress");
|
|
return Err(OperationError::InvalidState);
|
|
}
|
|
|
|
let ncred = session
|
|
.primary
|
|
.as_ref()
|
|
.map(|cred| cred.remove_totp(label))
|
|
.ok_or_else(|| {
|
|
admin_error!("Try to remove TOTP, but no primary credential stub exists");
|
|
OperationError::InvalidState
|
|
})?;
|
|
|
|
session.primary = Some(ncred);
|
|
|
|
// Set the state to None.
|
|
session.mfaregstate = MfaRegState::None;
|
|
Ok(session.deref().into())
|
|
}
|
|
|
|
pub fn credential_primary_init_backup_codes(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !matches!(session.primary_state, CredentialState::Modifiable) {
|
|
error!("Session does not have permission to modify primary credential");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
// I think we override/map the status to inject the codes as a once-off state message.
|
|
|
|
let codes = backup_code_from_random();
|
|
|
|
let ncred = session
|
|
.primary
|
|
.as_ref()
|
|
.ok_or_else(|| {
|
|
error!("Tried to add backup codes, but no primary credential stub exists");
|
|
OperationError::InvalidState
|
|
})
|
|
.and_then(|cred|
|
|
cred.update_backup_code(BackupCodes::new(codes.clone()))
|
|
.map_err(|_| {
|
|
error!("Tried to add backup codes, but MFA is not enabled on this credential yet");
|
|
OperationError::InvalidState
|
|
})
|
|
)
|
|
?;
|
|
|
|
session.primary = Some(ncred);
|
|
|
|
Ok(session.deref().into()).map(|mut status: CredentialUpdateSessionStatus| {
|
|
status.mfaregstate = MfaRegStateStatus::BackupCodes(codes);
|
|
status
|
|
})
|
|
}
|
|
|
|
pub fn credential_primary_remove_backup_codes(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !matches!(session.primary_state, CredentialState::Modifiable) {
|
|
error!("Session does not have permission to modify primary credential");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
let ncred = session
|
|
.primary
|
|
.as_ref()
|
|
.ok_or_else(|| {
|
|
admin_error!("Tried to add backup codes, but no primary credential stub exists");
|
|
OperationError::InvalidState
|
|
})
|
|
.and_then(|cred|
|
|
cred.remove_backup_code()
|
|
.map_err(|_| {
|
|
admin_error!("Tried to remove backup codes, but MFA is not enabled on this credential yet");
|
|
OperationError::InvalidState
|
|
})
|
|
)
|
|
?;
|
|
|
|
session.primary = Some(ncred);
|
|
|
|
Ok(session.deref().into())
|
|
}
|
|
|
|
pub fn credential_primary_delete(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !(matches!(session.primary_state, CredentialState::Modifiable)
|
|
|| matches!(session.primary_state, CredentialState::DeleteOnly))
|
|
{
|
|
error!("Session does not have permission to modify primary credential");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
session.primary = None;
|
|
Ok(session.deref().into())
|
|
}
|
|
|
|
pub fn credential_passkey_init(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !matches!(session.passkeys_state, CredentialState::Modifiable) {
|
|
error!("Session does not have permission to modify passkeys");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
if !matches!(session.mfaregstate, MfaRegState::None) {
|
|
debug!("Clearing incomplete mfareg");
|
|
}
|
|
|
|
let (ccr, pk_reg) = self
|
|
.webauthn
|
|
.start_passkey_registration(
|
|
session.account.uuid,
|
|
&session.account.spn,
|
|
&session.account.displayname,
|
|
session.account.existing_credential_id_list(),
|
|
)
|
|
.map_err(|e| {
|
|
error!(eclass=?e, emsg=%e, "Unable to start passkey registration");
|
|
OperationError::Webauthn
|
|
})?;
|
|
|
|
session.mfaregstate = MfaRegState::Passkey(Box::new(ccr), pk_reg);
|
|
// Now that it's in the state, it'll be in the status when returned.
|
|
Ok(session.deref().into())
|
|
}
|
|
|
|
pub fn credential_passkey_finish(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
label: String,
|
|
reg: &RegisterPublicKeyCredential,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !matches!(session.passkeys_state, CredentialState::Modifiable) {
|
|
error!("Session does not have permission to modify passkeys");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
match &session.mfaregstate {
|
|
MfaRegState::Passkey(_ccr, pk_reg) => {
|
|
let reg_result = self.webauthn.finish_passkey_registration(reg, pk_reg);
|
|
|
|
// Clean up state before returning results.
|
|
session.mfaregstate = MfaRegState::None;
|
|
|
|
match reg_result {
|
|
Ok(passkey) => {
|
|
let pk_id = Uuid::new_v4();
|
|
session.passkeys.insert(pk_id, (label, passkey));
|
|
|
|
let cu_status: CredentialUpdateSessionStatus = session.deref().into();
|
|
Ok(cu_status)
|
|
}
|
|
Err(WebauthnError::UserNotVerified) => {
|
|
let mut cu_status: CredentialUpdateSessionStatus = session.deref().into();
|
|
cu_status.append_ephemeral_warning(
|
|
CredentialUpdateSessionStatusWarnings::WebauthnUserVerificationRequired,
|
|
);
|
|
Ok(cu_status)
|
|
}
|
|
Err(err) => {
|
|
error!(eclass=?err, emsg=%err, "Unable to complete passkey registration");
|
|
Err(OperationError::CU0002WebauthnRegistrationError)
|
|
}
|
|
}
|
|
}
|
|
invalid_state => {
|
|
warn!(?invalid_state);
|
|
Err(OperationError::InvalidRequestState)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn credential_passkey_remove(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
uuid: Uuid,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !(matches!(session.passkeys_state, CredentialState::Modifiable)
|
|
|| matches!(session.passkeys_state, CredentialState::DeleteOnly))
|
|
{
|
|
error!("Session does not have permission to modify passkeys");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
// No-op if not present
|
|
session.passkeys.remove(&uuid);
|
|
|
|
Ok(session.deref().into())
|
|
}
|
|
|
|
pub fn credential_attested_passkey_init(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !matches!(session.attested_passkeys_state, CredentialState::Modifiable) {
|
|
error!("Session does not have permission to modify attested passkeys");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
if !matches!(session.mfaregstate, MfaRegState::None) {
|
|
debug!("Cancelling abandoned mfareg");
|
|
}
|
|
|
|
let att_ca_list = session
|
|
.resolved_account_policy
|
|
.webauthn_attestation_ca_list()
|
|
.cloned()
|
|
.ok_or_else(|| {
|
|
error!(
|
|
"No attestation CA list is available, can not proceed with attested passkeys."
|
|
);
|
|
OperationError::AccessDenied
|
|
})?;
|
|
|
|
let (ccr, pk_reg) = self
|
|
.webauthn
|
|
.start_attested_passkey_registration(
|
|
session.account.uuid,
|
|
&session.account.spn,
|
|
&session.account.displayname,
|
|
session.account.existing_credential_id_list(),
|
|
att_ca_list,
|
|
None,
|
|
)
|
|
.map_err(|e| {
|
|
error!(eclass=?e, emsg=%e, "Unable to start passkey registration");
|
|
OperationError::Webauthn
|
|
})?;
|
|
|
|
session.mfaregstate = MfaRegState::AttestedPasskey(Box::new(ccr), pk_reg);
|
|
// Now that it's in the state, it'll be in the status when returned.
|
|
Ok(session.deref().into())
|
|
}
|
|
|
|
pub fn credential_attested_passkey_finish(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
label: String,
|
|
reg: &RegisterPublicKeyCredential,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !matches!(session.attested_passkeys_state, CredentialState::Modifiable) {
|
|
error!("Session does not have permission to modify attested passkeys");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
match &session.mfaregstate {
|
|
MfaRegState::AttestedPasskey(_ccr, pk_reg) => {
|
|
let result = self
|
|
.webauthn
|
|
.finish_attested_passkey_registration(reg, pk_reg)
|
|
.map_err(|e| {
|
|
error!(eclass=?e, emsg=%e, "Unable to complete attested passkey registration");
|
|
|
|
match e {
|
|
WebauthnError::AttestationChainNotTrusted(_)
|
|
| WebauthnError::AttestationNotVerifiable => {
|
|
OperationError::CU0001WebauthnAttestationNotTrusted
|
|
},
|
|
WebauthnError::UserNotVerified => {
|
|
OperationError::CU0003WebauthnUserNotVerified
|
|
},
|
|
_ => OperationError::CU0002WebauthnRegistrationError,
|
|
}
|
|
});
|
|
|
|
// The reg is done. Clean up state before returning errors.
|
|
session.mfaregstate = MfaRegState::None;
|
|
|
|
let passkey = result?;
|
|
trace!(?passkey);
|
|
|
|
let pk_id = Uuid::new_v4();
|
|
session.attested_passkeys.insert(pk_id, (label, passkey));
|
|
|
|
trace!(?session.attested_passkeys);
|
|
|
|
Ok(session.deref().into())
|
|
}
|
|
_ => Err(OperationError::InvalidRequestState),
|
|
}
|
|
}
|
|
|
|
pub fn credential_attested_passkey_remove(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
uuid: Uuid,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !(matches!(session.attested_passkeys_state, CredentialState::Modifiable)
|
|
|| matches!(session.attested_passkeys_state, CredentialState::DeleteOnly))
|
|
{
|
|
error!("Session does not have permission to modify attested passkeys");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
// No-op if not present
|
|
session.attested_passkeys.remove(&uuid);
|
|
|
|
Ok(session.deref().into())
|
|
}
|
|
|
|
#[instrument(level = "trace", skip(cust, self))]
|
|
pub fn credential_unix_set_password(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
pw: &str,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !matches!(session.unixcred_state, CredentialState::Modifiable) {
|
|
error!("Session does not have permission to modify unix credential");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
self.check_password_quality(
|
|
pw,
|
|
&session.resolved_account_policy,
|
|
session.account.related_inputs().as_slice(),
|
|
session.account.radius_secret.as_deref(),
|
|
)
|
|
.map_err(|e| match e {
|
|
PasswordQuality::TooShort(sz) => {
|
|
OperationError::PasswordQuality(vec![PasswordFeedback::TooShort(sz)])
|
|
}
|
|
PasswordQuality::BadListed => {
|
|
OperationError::PasswordQuality(vec![PasswordFeedback::BadListed])
|
|
}
|
|
PasswordQuality::DontReusePasswords => {
|
|
OperationError::PasswordQuality(vec![PasswordFeedback::DontReusePasswords])
|
|
}
|
|
PasswordQuality::Feedback(feedback) => OperationError::PasswordQuality(feedback),
|
|
})?;
|
|
|
|
let ncred = match &session.unixcred {
|
|
Some(unixcred) => {
|
|
// Is there a need to update the uuid of the cred re softlocks?
|
|
unixcred.set_password(self.crypto_policy, pw)?
|
|
}
|
|
None => Credential::new_password_only(self.crypto_policy, pw)?,
|
|
};
|
|
|
|
session.unixcred = Some(ncred);
|
|
Ok(session.deref().into())
|
|
}
|
|
|
|
pub fn credential_unix_delete(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !(matches!(session.unixcred_state, CredentialState::Modifiable)
|
|
|| matches!(session.unixcred_state, CredentialState::DeleteOnly))
|
|
{
|
|
error!("Session does not have permission to modify unix credential");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
session.unixcred = None;
|
|
Ok(session.deref().into())
|
|
}
|
|
|
|
#[instrument(level = "trace", skip(cust, self))]
|
|
pub fn credential_sshkey_add(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
label: String,
|
|
sshpubkey: SshPublicKey,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !matches!(session.unixcred_state, CredentialState::Modifiable) {
|
|
error!("Session does not have permission to modify unix credential");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
// Check the label.
|
|
if !LABEL_RE.is_match(&label) {
|
|
error!("SSH Public Key label invalid");
|
|
return Err(OperationError::InvalidLabel);
|
|
}
|
|
|
|
if session.sshkeys.contains_key(&label) {
|
|
error!("SSH Public Key label duplicate");
|
|
return Err(OperationError::DuplicateLabel);
|
|
}
|
|
|
|
if session.sshkeys.values().any(|sk| *sk == sshpubkey) {
|
|
error!("SSH Public Key duplicate");
|
|
return Err(OperationError::DuplicateKey);
|
|
}
|
|
|
|
session.sshkeys.insert(label, sshpubkey);
|
|
|
|
Ok(session.deref().into())
|
|
}
|
|
|
|
pub fn credential_sshkey_remove(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
label: &str,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
|
|
if !(matches!(session.sshkeys_state, CredentialState::Modifiable)
|
|
|| matches!(session.sshkeys_state, CredentialState::DeleteOnly))
|
|
{
|
|
error!("Session does not have permission to modify sshkeys");
|
|
return Err(OperationError::AccessDenied);
|
|
};
|
|
|
|
session.sshkeys.remove(label).ok_or_else(|| {
|
|
error!("No such key for label");
|
|
OperationError::NoMatchingEntries
|
|
})?;
|
|
|
|
// session.unixcred = None;
|
|
|
|
Ok(session.deref().into())
|
|
}
|
|
|
|
pub fn credential_update_cancel_mfareg(
|
|
&self,
|
|
cust: &CredentialUpdateSessionToken,
|
|
ct: Duration,
|
|
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
|
let session_handle = self.get_current_session(cust, ct)?;
|
|
let mut session = session_handle.try_lock().map_err(|_| {
|
|
admin_error!("Session already locked, unable to proceed.");
|
|
OperationError::InvalidState
|
|
})?;
|
|
trace!(?session);
|
|
session.mfaregstate = MfaRegState::None;
|
|
Ok(session.deref().into())
|
|
}
|
|
|
|
// Generate password?
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use compact_jwt::JwsCompact;
|
|
use std::time::Duration;
|
|
|
|
use kanidm_proto::internal::{CUExtPortal, CredentialDetailType, PasswordFeedback};
|
|
use kanidm_proto::v1::{AuthAllowed, AuthIssueSession, AuthMech, UnixUserToken};
|
|
use uuid::uuid;
|
|
use webauthn_authenticator_rs::softpasskey::SoftPasskey;
|
|
use webauthn_authenticator_rs::softtoken::{self, SoftToken};
|
|
use webauthn_authenticator_rs::{AuthenticatorBackend, WebauthnAuthenticator};
|
|
use webauthn_rs::prelude::AttestationCaListBuilder;
|
|
|
|
use super::{
|
|
CredentialState, CredentialUpdateSessionStatus, CredentialUpdateSessionStatusWarnings,
|
|
CredentialUpdateSessionToken, InitCredentialUpdateEvent, InitCredentialUpdateIntentEvent,
|
|
MfaRegStateStatus, MAXIMUM_CRED_UPDATE_TTL, MAXIMUM_INTENT_TTL, MINIMUM_INTENT_TTL,
|
|
};
|
|
use crate::credential::totp::Totp;
|
|
use crate::event::CreateEvent;
|
|
use crate::idm::audit::AuditEvent;
|
|
use crate::idm::delayed::DelayedAction;
|
|
use crate::idm::event::{
|
|
AuthEvent, AuthResult, RegenerateRadiusSecretEvent, UnixUserAuthEvent,
|
|
};
|
|
use crate::idm::server::{IdmServer, IdmServerCredUpdateTransaction, IdmServerDelayed};
|
|
use crate::idm::AuthState;
|
|
use crate::prelude::*;
|
|
use crate::utils::password_from_random_len;
|
|
use crate::value::CredentialType;
|
|
use sshkey_attest::proto::PublicKey as SshPublicKey;
|
|
|
|
const TEST_CURRENT_TIME: u64 = 6000;
|
|
const TESTPERSON_UUID: Uuid = uuid!("cf231fea-1a8f-4410-a520-fd9b1a379c86");
|
|
|
|
const SSHKEY_VALID_1: &str = "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBENubZikrb8hu+HeVRdZ0pp/VAk2qv4JDbuJhvD0yNdWDL2e3cBbERiDeNPkWx58Q4rVnxkbV1fa8E2waRtT91wAAAAEc3NoOg== testuser@fidokey";
|
|
const SSHKEY_VALID_2: &str = "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBIbkSsdGCRoW6v0nO/3vNYPhG20YhWU0wQPY7x52EOb4dmYhC4IJfzVDpEPg313BxWRKQglb5RQ1PPkou7JFyCUAAAAEc3NoOg== testuser@fidokey";
|
|
const SSHKEY_INVALID: &str = "sk-ecrsa-sha9000-nistp@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBIbkSsdGCRoW6v0nO/3vNYPhG20YhWU0wQPY7x52EOb4dmYhC4IJfzVDpEPg313BxWRKQglb5RQ1PPkou7JFyCUAAAAEc3NoOg== badkey@rejectme";
|
|
|
|
#[idm_test]
|
|
async fn credential_update_session_init(
|
|
idms: &IdmServer,
|
|
_idms_delayed: &mut IdmServerDelayed,
|
|
) {
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
|
|
|
|
let testaccount_uuid = Uuid::new_v4();
|
|
|
|
let e1 = entry_init!(
|
|
(Attribute::Class, EntryClass::Object.to_value()),
|
|
(Attribute::Class, EntryClass::Account.to_value()),
|
|
(Attribute::Class, EntryClass::ServiceAccount.to_value()),
|
|
(Attribute::Name, Value::new_iname("user_account_only")),
|
|
(Attribute::Uuid, Value::Uuid(testaccount_uuid)),
|
|
(Attribute::Description, Value::new_utf8s("testaccount")),
|
|
(Attribute::DisplayName, Value::new_utf8s("testaccount"))
|
|
);
|
|
|
|
let e2 = entry_init!(
|
|
(Attribute::Class, EntryClass::Object.to_value()),
|
|
(Attribute::Class, EntryClass::Account.to_value()),
|
|
(Attribute::Class, EntryClass::PosixAccount.to_value()),
|
|
(Attribute::Class, EntryClass::Person.to_value()),
|
|
(Attribute::Name, Value::new_iname("testperson")),
|
|
(Attribute::Uuid, Value::Uuid(TESTPERSON_UUID)),
|
|
(Attribute::Description, Value::new_utf8s("testperson")),
|
|
(Attribute::DisplayName, Value::new_utf8s("testperson"))
|
|
);
|
|
|
|
let ce = CreateEvent::new_internal(vec![e1, e2]);
|
|
let cr = idms_prox_write.qs_write.create(&ce);
|
|
assert!(cr.is_ok());
|
|
|
|
let testaccount = idms_prox_write
|
|
.qs_write
|
|
.internal_search_uuid(testaccount_uuid)
|
|
.expect("failed");
|
|
|
|
let testperson = idms_prox_write
|
|
.qs_write
|
|
.internal_search_uuid(TESTPERSON_UUID)
|
|
.expect("failed");
|
|
|
|
let idm_admin = idms_prox_write
|
|
.qs_write
|
|
.internal_search_uuid(UUID_IDM_ADMIN)
|
|
.expect("failed");
|
|
|
|
// user without permission - fail
|
|
// - accounts don't have self-write permission.
|
|
|
|
let cur = idms_prox_write.init_credential_update(
|
|
&InitCredentialUpdateEvent::new_impersonate_entry(testaccount),
|
|
ct,
|
|
);
|
|
|
|
assert!(matches!(cur, Err(OperationError::NotAuthorised)));
|
|
|
|
// user with permission - success
|
|
|
|
let cur = idms_prox_write.init_credential_update(
|
|
&InitCredentialUpdateEvent::new_impersonate_entry(testperson),
|
|
ct,
|
|
);
|
|
|
|
assert!(cur.is_ok());
|
|
|
|
// create intent token without permission - fail
|
|
|
|
// create intent token with permission - success
|
|
|
|
let cur = idms_prox_write.init_credential_update_intent(
|
|
&InitCredentialUpdateIntentEvent::new_impersonate_entry(
|
|
idm_admin,
|
|
TESTPERSON_UUID,
|
|
MINIMUM_INTENT_TTL,
|
|
),
|
|
ct,
|
|
);
|
|
|
|
assert!(cur.is_ok());
|
|
let intent_tok = cur.expect("Failed to create intent token!");
|
|
|
|
// exchange intent token - invalid - fail
|
|
// Expired
|
|
let cur = idms_prox_write
|
|
.exchange_intent_credential_update(intent_tok.clone().into(), ct + MINIMUM_INTENT_TTL);
|
|
|
|
assert!(matches!(cur, Err(OperationError::SessionExpired)));
|
|
|
|
let cur = idms_prox_write
|
|
.exchange_intent_credential_update(intent_tok.clone().into(), ct + MAXIMUM_INTENT_TTL);
|
|
|
|
assert!(matches!(cur, Err(OperationError::SessionExpired)));
|
|
|
|
// exchange intent token - success
|
|
let (cust_a, _c_status) = idms_prox_write
|
|
.exchange_intent_credential_update(intent_tok.clone().into(), ct)
|
|
.unwrap();
|
|
|
|
// Session in progress - This will succeed and then block the former success from
|
|
// committing.
|
|
let (cust_b, _c_status) = idms_prox_write
|
|
.exchange_intent_credential_update(intent_tok.into(), ct + Duration::from_secs(1))
|
|
.unwrap();
|
|
|
|
let cur = idms_prox_write.commit_credential_update(&cust_a, ct);
|
|
|
|
// Fails as the txn was orphaned.
|
|
trace!(?cur);
|
|
assert!(cur.is_err());
|
|
|
|
// Success - this was the second use of the token and is valid.
|
|
let _ = idms_prox_write.commit_credential_update(&cust_b, ct);
|
|
|
|
idms_prox_write.commit().expect("Failed to commit txn");
|
|
}
|
|
|
|
async fn setup_test_session(
|
|
idms: &IdmServer,
|
|
ct: Duration,
|
|
) -> (CredentialUpdateSessionToken, CredentialUpdateSessionStatus) {
|
|
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
|
|
|
|
// Remove the default all persons policy, it interferes with our test.
|
|
let modlist = ModifyList::new_purge(Attribute::CredentialTypeMinimum);
|
|
idms_prox_write
|
|
.qs_write
|
|
.internal_modify_uuid(UUID_IDM_ALL_PERSONS, &modlist)
|
|
.expect("Unable to change default session exp");
|
|
|
|
let e2 = entry_init!(
|
|
(Attribute::Class, EntryClass::Object.to_value()),
|
|
(Attribute::Class, EntryClass::Account.to_value()),
|
|
(Attribute::Class, EntryClass::PosixAccount.to_value()),
|
|
(Attribute::Class, EntryClass::Person.to_value()),
|
|
(Attribute::Name, Value::new_iname("testperson")),
|
|
(Attribute::Uuid, Value::Uuid(TESTPERSON_UUID)),
|
|
(Attribute::Description, Value::new_utf8s("testperson")),
|
|
(Attribute::DisplayName, Value::new_utf8s("testperson"))
|
|
);
|
|
|
|
let ce = CreateEvent::new_internal(vec![e2]);
|
|
let cr = idms_prox_write.qs_write.create(&ce);
|
|
assert!(cr.is_ok());
|
|
|
|
let testperson = idms_prox_write
|
|
.qs_write
|
|
.internal_search_uuid(TESTPERSON_UUID)
|
|
.expect("failed");
|
|
|
|
// Setup the radius creds to ensure we don't use them anywhere else.
|
|
let rrse = RegenerateRadiusSecretEvent::new_internal(TESTPERSON_UUID);
|
|
|
|
let _ = idms_prox_write
|
|
.regenerate_radius_secret(&rrse)
|
|
.expect("Failed to reset radius credential 1");
|
|
|
|
let cur = idms_prox_write.init_credential_update(
|
|
&InitCredentialUpdateEvent::new_impersonate_entry(testperson),
|
|
ct,
|
|
);
|
|
|
|
idms_prox_write.commit().expect("Failed to commit txn");
|
|
|
|
cur.expect("Failed to start update")
|
|
}
|
|
|
|
async fn renew_test_session(
|
|
idms: &IdmServer,
|
|
ct: Duration,
|
|
) -> (CredentialUpdateSessionToken, CredentialUpdateSessionStatus) {
|
|
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
|
|
|
|
let testperson = idms_prox_write
|
|
.qs_write
|
|
.internal_search_uuid(TESTPERSON_UUID)
|
|
.expect("failed");
|
|
|
|
let cur = idms_prox_write.init_credential_update(
|
|
&InitCredentialUpdateEvent::new_impersonate_entry(testperson),
|
|
ct,
|
|
);
|
|
|
|
trace!(renew_test_session_result = ?cur);
|
|
|
|
idms_prox_write.commit().expect("Failed to commit txn");
|
|
|
|
cur.expect("Failed to start update")
|
|
}
|
|
|
|
async fn commit_session(idms: &IdmServer, ct: Duration, cust: CredentialUpdateSessionToken) {
|
|
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
|
|
|
|
idms_prox_write
|
|
.commit_credential_update(&cust, ct)
|
|
.expect("Failed to commit credential update.");
|
|
|
|
idms_prox_write.commit().expect("Failed to commit txn");
|
|
}
|
|
|
|
async fn check_testperson_password(
|
|
idms: &IdmServer,
|
|
idms_delayed: &mut IdmServerDelayed,
|
|
pw: &str,
|
|
ct: Duration,
|
|
) -> Option<JwsCompact> {
|
|
let mut idms_auth = idms.auth().await.unwrap();
|
|
|
|
let auth_init = AuthEvent::named_init("testperson");
|
|
|
|
let r1 = idms_auth
|
|
.auth(&auth_init, ct, Source::Internal.into())
|
|
.await;
|
|
let ar = r1.unwrap();
|
|
let AuthResult { sessionid, state } = ar;
|
|
|
|
if !matches!(state, AuthState::Choose(_)) {
|
|
debug!("Can't proceed - {:?}", state);
|
|
return None;
|
|
};
|
|
|
|
let auth_begin = AuthEvent::begin_mech(sessionid, AuthMech::Password);
|
|
|
|
let r2 = idms_auth
|
|
.auth(&auth_begin, ct, Source::Internal.into())
|
|
.await;
|
|
let ar = r2.unwrap();
|
|
let AuthResult { sessionid, state } = ar;
|
|
|
|
assert!(matches!(state, AuthState::Continue(_)));
|
|
|
|
let pw_step = AuthEvent::cred_step_password(sessionid, pw);
|
|
|
|
// Expect success
|
|
let r2 = idms_auth.auth(&pw_step, ct, Source::Internal.into()).await;
|
|
debug!("r2 ==> {:?}", r2);
|
|
idms_auth.commit().expect("Must not fail");
|
|
|
|
match r2 {
|
|
Ok(AuthResult {
|
|
sessionid: _,
|
|
state: AuthState::Success(token, AuthIssueSession::Token),
|
|
}) => {
|
|
// Process the auth session
|
|
let da = idms_delayed.try_recv().expect("invalid");
|
|
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
|
|
|
|
Some(*token)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
async fn check_testperson_unix_password(
|
|
idms: &IdmServer,
|
|
// idms_delayed: &mut IdmServerDelayed,
|
|
pw: &str,
|
|
ct: Duration,
|
|
) -> Option<UnixUserToken> {
|
|
let mut idms_auth = idms.auth().await.unwrap();
|
|
|
|
let auth_event = UnixUserAuthEvent::new_internal(TESTPERSON_UUID, pw);
|
|
|
|
idms_auth
|
|
.auth_unix(&auth_event, ct)
|
|
.await
|
|
.expect("Unable to perform unix authentication")
|
|
}
|
|
|
|
async fn check_testperson_password_totp(
|
|
idms: &IdmServer,
|
|
idms_delayed: &mut IdmServerDelayed,
|
|
pw: &str,
|
|
token: &Totp,
|
|
ct: Duration,
|
|
) -> Option<JwsCompact> {
|
|
let mut idms_auth = idms.auth().await.unwrap();
|
|
|
|
let auth_init = AuthEvent::named_init("testperson");
|
|
|
|
let r1 = idms_auth
|
|
.auth(&auth_init, ct, Source::Internal.into())
|
|
.await;
|
|
let ar = r1.unwrap();
|
|
let AuthResult { sessionid, state } = ar;
|
|
|
|
if !matches!(state, AuthState::Choose(_)) {
|
|
debug!("Can't proceed - {:?}", state);
|
|
return None;
|
|
};
|
|
|
|
let auth_begin = AuthEvent::begin_mech(sessionid, AuthMech::PasswordTotp);
|
|
|
|
let r2 = idms_auth
|
|
.auth(&auth_begin, ct, Source::Internal.into())
|
|
.await;
|
|
let ar = r2.unwrap();
|
|
let AuthResult { sessionid, state } = ar;
|
|
|
|
assert!(matches!(state, AuthState::Continue(_)));
|
|
|
|
let totp = token
|
|
.do_totp_duration_from_epoch(&ct)
|
|
.expect("Failed to perform totp step");
|
|
|
|
let totp_step = AuthEvent::cred_step_totp(sessionid, totp);
|
|
let r2 = idms_auth
|
|
.auth(&totp_step, ct, Source::Internal.into())
|
|
.await;
|
|
let ar = r2.unwrap();
|
|
let AuthResult { sessionid, state } = ar;
|
|
|
|
assert!(matches!(state, AuthState::Continue(_)));
|
|
|
|
let pw_step = AuthEvent::cred_step_password(sessionid, pw);
|
|
|
|
// Expect success
|
|
let r3 = idms_auth.auth(&pw_step, ct, Source::Internal.into()).await;
|
|
debug!("r3 ==> {:?}", r3);
|
|
idms_auth.commit().expect("Must not fail");
|
|
|
|
match r3 {
|
|
Ok(AuthResult {
|
|
sessionid: _,
|
|
state: AuthState::Success(token, AuthIssueSession::Token),
|
|
}) => {
|
|
// Process the auth session
|
|
let da = idms_delayed.try_recv().expect("invalid");
|
|
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
|
|
Some(*token)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
async fn check_testperson_password_backup_code(
|
|
idms: &IdmServer,
|
|
idms_delayed: &mut IdmServerDelayed,
|
|
pw: &str,
|
|
code: &str,
|
|
ct: Duration,
|
|
) -> Option<JwsCompact> {
|
|
let mut idms_auth = idms.auth().await.unwrap();
|
|
|
|
let auth_init = AuthEvent::named_init("testperson");
|
|
|
|
let r1 = idms_auth
|
|
.auth(&auth_init, ct, Source::Internal.into())
|
|
.await;
|
|
let ar = r1.unwrap();
|
|
let AuthResult { sessionid, state } = ar;
|
|
|
|
if !matches!(state, AuthState::Choose(_)) {
|
|
debug!("Can't proceed - {:?}", state);
|
|
return None;
|
|
};
|
|
|
|
let auth_begin = AuthEvent::begin_mech(sessionid, AuthMech::PasswordBackupCode);
|
|
|
|
let r2 = idms_auth
|
|
.auth(&auth_begin, ct, Source::Internal.into())
|
|
.await;
|
|
let ar = r2.unwrap();
|
|
let AuthResult { sessionid, state } = ar;
|
|
|
|
assert!(matches!(state, AuthState::Continue(_)));
|
|
|
|
let code_step = AuthEvent::cred_step_backup_code(sessionid, code);
|
|
let r2 = idms_auth
|
|
.auth(&code_step, ct, Source::Internal.into())
|
|
.await;
|
|
let ar = r2.unwrap();
|
|
let AuthResult { sessionid, state } = ar;
|
|
|
|
assert!(matches!(state, AuthState::Continue(_)));
|
|
|
|
let pw_step = AuthEvent::cred_step_password(sessionid, pw);
|
|
|
|
// Expect success
|
|
let r3 = idms_auth.auth(&pw_step, ct, Source::Internal.into()).await;
|
|
debug!("r3 ==> {:?}", r3);
|
|
idms_auth.commit().expect("Must not fail");
|
|
|
|
match r3 {
|
|
Ok(AuthResult {
|
|
sessionid: _,
|
|
state: AuthState::Success(token, AuthIssueSession::Token),
|
|
}) => {
|
|
// There now should be a backup code invalidation present
|
|
let da = idms_delayed.try_recv().expect("invalid");
|
|
assert!(matches!(da, DelayedAction::BackupCodeRemoval(_)));
|
|
let r = idms.delayed_action(ct, da).await;
|
|
assert!(r.is_ok());
|
|
|
|
// Process the auth session
|
|
let da = idms_delayed.try_recv().expect("invalid");
|
|
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
|
|
Some(*token)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
async fn check_testperson_passkey<T: AuthenticatorBackend>(
|
|
idms: &IdmServer,
|
|
idms_delayed: &mut IdmServerDelayed,
|
|
wa: &mut WebauthnAuthenticator<T>,
|
|
origin: Url,
|
|
ct: Duration,
|
|
) -> Option<JwsCompact> {
|
|
let mut idms_auth = idms.auth().await.unwrap();
|
|
|
|
let auth_init = AuthEvent::named_init("testperson");
|
|
|
|
let r1 = idms_auth
|
|
.auth(&auth_init, ct, Source::Internal.into())
|
|
.await;
|
|
let ar = r1.unwrap();
|
|
let AuthResult { sessionid, state } = ar;
|
|
|
|
if !matches!(state, AuthState::Choose(_)) {
|
|
debug!("Can't proceed - {:?}", state);
|
|
return None;
|
|
};
|
|
|
|
let auth_begin = AuthEvent::begin_mech(sessionid, AuthMech::Passkey);
|
|
|
|
let r2 = idms_auth
|
|
.auth(&auth_begin, ct, Source::Internal.into())
|
|
.await;
|
|
let ar = r2.unwrap();
|
|
let AuthResult { sessionid, state } = ar;
|
|
|
|
trace!(?state);
|
|
|
|
let rcr = match state {
|
|
AuthState::Continue(mut allowed) => match allowed.pop() {
|
|
Some(AuthAllowed::Passkey(rcr)) => rcr,
|
|
_ => unreachable!(),
|
|
},
|
|
_ => unreachable!(),
|
|
};
|
|
|
|
trace!(?rcr);
|
|
|
|
let resp = wa
|
|
.do_authentication(origin, rcr)
|
|
.expect("failed to use softtoken to authenticate");
|
|
|
|
let passkey_step = AuthEvent::cred_step_passkey(sessionid, resp);
|
|
|
|
let r3 = idms_auth
|
|
.auth(&passkey_step, ct, Source::Internal.into())
|
|
.await;
|
|
debug!("r3 ==> {:?}", r3);
|
|
idms_auth.commit().expect("Must not fail");
|
|
|
|
match r3 {
|
|
Ok(AuthResult {
|
|
sessionid: _,
|
|
state: AuthState::Success(token, AuthIssueSession::Token),
|
|
}) => {
|
|
// Process the webauthn update
|
|
let da = idms_delayed.try_recv().expect("invalid");
|
|
assert!(matches!(da, DelayedAction::WebauthnCounterIncrement(_)));
|
|
let r = idms.delayed_action(ct, da).await;
|
|
assert!(r.is_ok());
|
|
|
|
// Process the auth session
|
|
let da = idms_delayed.try_recv().expect("invalid");
|
|
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
|
|
|
|
Some(*token)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[idm_test]
|
|
async fn credential_update_session_cleanup(
|
|
idms: &IdmServer,
|
|
_idms_delayed: &mut IdmServerDelayed,
|
|
) {
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
let (cust, _) = setup_test_session(idms, ct).await;
|
|
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
// The session exists
|
|
let c_status = cutxn.credential_update_status(&cust, ct);
|
|
assert!(c_status.is_ok());
|
|
drop(cutxn);
|
|
|
|
// Making a new session is what triggers the clean of old sessions.
|
|
let (_cust, _) =
|
|
renew_test_session(idms, ct + MAXIMUM_CRED_UPDATE_TTL + Duration::from_secs(1)).await;
|
|
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
// Now fake going back in time .... allows the token to decrypt, but the session
|
|
// is gone anyway!
|
|
let c_status = cutxn
|
|
.credential_update_status(&cust, ct)
|
|
.expect_err("Session is still valid!");
|
|
assert!(matches!(c_status, OperationError::InvalidState));
|
|
}
|
|
|
|
#[idm_test]
|
|
async fn credential_update_onboarding_create_new_pw(
|
|
idms: &IdmServer,
|
|
idms_delayed: &mut IdmServerDelayed,
|
|
) {
|
|
let test_pw = "fo3EitierohF9AelaNgiem0Ei6vup4equo1Oogeevaetehah8Tobeengae3Ci0ooh0uki";
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
|
|
let (cust, _) = setup_test_session(idms, ct).await;
|
|
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
// Get the credential status - this should tell
|
|
// us the details of the credentials, as well as
|
|
// if they are ready and valid to commit?
|
|
let c_status = cutxn
|
|
.credential_update_status(&cust, ct)
|
|
.expect("Failed to get the current session status.");
|
|
|
|
trace!(?c_status);
|
|
|
|
assert!(c_status.primary.is_none());
|
|
|
|
// Test initially creating a credential.
|
|
// - pw first
|
|
let c_status = cutxn
|
|
.credential_primary_set_password(&cust, ct, test_pw)
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
assert!(c_status.can_commit);
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
// Check it works!
|
|
assert!(check_testperson_password(idms, idms_delayed, test_pw, ct)
|
|
.await
|
|
.is_some());
|
|
|
|
// Test deleting the pw
|
|
let (cust, _) = renew_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
let c_status = cutxn
|
|
.credential_update_status(&cust, ct)
|
|
.expect("Failed to get the current session status.");
|
|
trace!(?c_status);
|
|
assert!(c_status.primary.is_some());
|
|
|
|
let c_status = cutxn
|
|
.credential_primary_delete(&cust, ct)
|
|
.expect("Failed to delete the primary cred");
|
|
trace!(?c_status);
|
|
assert!(c_status.primary.is_none());
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
// Must fail now!
|
|
assert!(check_testperson_password(idms, idms_delayed, test_pw, ct)
|
|
.await
|
|
.is_none());
|
|
}
|
|
|
|
#[idm_test]
|
|
async fn credential_update_password_quality_checks(
|
|
idms: &IdmServer,
|
|
_idms_delayed: &mut IdmServerDelayed,
|
|
) {
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
let (cust, _) = setup_test_session(idms, ct).await;
|
|
|
|
// Get the radius pw
|
|
|
|
let mut r_txn = idms.proxy_read().await.unwrap();
|
|
|
|
let radius_secret = r_txn
|
|
.qs_read
|
|
.internal_search_uuid(TESTPERSON_UUID)
|
|
.expect("No such entry")
|
|
.get_ava_single_secret(Attribute::RadiusSecret)
|
|
.expect("No radius secret found")
|
|
.to_string();
|
|
|
|
drop(r_txn);
|
|
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
// Get the credential status - this should tell
|
|
// us the details of the credentials, as well as
|
|
// if they are ready and valid to commit?
|
|
let c_status = cutxn
|
|
.credential_update_status(&cust, ct)
|
|
.expect("Failed to get the current session status.");
|
|
|
|
trace!(?c_status);
|
|
|
|
assert!(c_status.primary.is_none());
|
|
|
|
// Test initially creating a credential.
|
|
// - pw first
|
|
|
|
let err = cutxn
|
|
.credential_primary_set_password(&cust, ct, "password")
|
|
.unwrap_err();
|
|
trace!(?err);
|
|
assert!(
|
|
matches!(err, OperationError::PasswordQuality(details) if details == vec!(PasswordFeedback::TooShort(PW_MIN_LENGTH),))
|
|
);
|
|
|
|
let err = cutxn
|
|
.credential_primary_set_password(&cust, ct, "password1234")
|
|
.unwrap_err();
|
|
trace!(?err);
|
|
assert!(
|
|
matches!(err, OperationError::PasswordQuality(details) if details
|
|
== vec!(
|
|
PasswordFeedback::AddAnotherWordOrTwo,
|
|
PasswordFeedback::ThisIsACommonPassword,
|
|
))
|
|
);
|
|
|
|
let err = cutxn
|
|
.credential_primary_set_password(&cust, ct, &radius_secret)
|
|
.unwrap_err();
|
|
trace!(?err);
|
|
assert!(
|
|
matches!(err, OperationError::PasswordQuality(details) if details == vec!(PasswordFeedback::DontReusePasswords,))
|
|
);
|
|
|
|
let err = cutxn
|
|
.credential_primary_set_password(&cust, ct, "testperson2023")
|
|
.unwrap_err();
|
|
trace!(?err);
|
|
assert!(
|
|
matches!(err, OperationError::PasswordQuality(details) if details == vec!(
|
|
PasswordFeedback::NamesAndSurnamesByThemselvesAreEasyToGuess,
|
|
PasswordFeedback::AvoidDatesAndYearsThatAreAssociatedWithYou,
|
|
))
|
|
);
|
|
|
|
let err = cutxn
|
|
.credential_primary_set_password(
|
|
&cust,
|
|
ct,
|
|
"demo_badlist_shohfie3aeci2oobur0aru9uushah6EiPi2woh4hohngoighaiRuepieN3ongoo1",
|
|
)
|
|
.unwrap_err();
|
|
trace!(?err);
|
|
assert!(
|
|
matches!(err, OperationError::PasswordQuality(details) if details == vec!(PasswordFeedback::BadListed))
|
|
);
|
|
|
|
assert!(c_status.can_commit);
|
|
|
|
drop(cutxn);
|
|
}
|
|
|
|
#[idm_test]
|
|
async fn credential_update_password_min_length_account_policy(
|
|
idms: &IdmServer,
|
|
_idms_delayed: &mut IdmServerDelayed,
|
|
) {
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
|
|
// Set the account policy min pw length
|
|
let test_pw_min_length = PW_MIN_LENGTH * 2;
|
|
|
|
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
|
|
|
|
let modlist = ModifyList::new_purge_and_set(
|
|
Attribute::AuthPasswordMinimumLength,
|
|
Value::Uint32(test_pw_min_length),
|
|
);
|
|
idms_prox_write
|
|
.qs_write
|
|
.internal_modify_uuid(UUID_IDM_ALL_ACCOUNTS, &modlist)
|
|
.expect("Unable to change default session exp");
|
|
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
// This now will affect all accounts for the next cred update.
|
|
|
|
let (cust, _) = setup_test_session(idms, ct).await;
|
|
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
// Get the credential status - this should tell
|
|
// us the details of the credentials, as well as
|
|
// if they are ready and valid to commit?
|
|
let c_status = cutxn
|
|
.credential_update_status(&cust, ct)
|
|
.expect("Failed to get the current session status.");
|
|
|
|
trace!(?c_status);
|
|
|
|
assert!(c_status.primary.is_none());
|
|
|
|
// Test initially creating a credential.
|
|
// - pw first
|
|
let pw = password_from_random_len(8);
|
|
let err = cutxn
|
|
.credential_primary_set_password(&cust, ct, &pw)
|
|
.unwrap_err();
|
|
trace!(?err);
|
|
assert!(
|
|
matches!(err, OperationError::PasswordQuality(details) if details == vec!(PasswordFeedback::TooShort(test_pw_min_length),))
|
|
);
|
|
|
|
// Test pw len of len minus 1
|
|
let pw = password_from_random_len(test_pw_min_length - 1);
|
|
let err = cutxn
|
|
.credential_primary_set_password(&cust, ct, &pw)
|
|
.unwrap_err();
|
|
trace!(?err);
|
|
assert!(matches!(err,OperationError::PasswordQuality(details)
|
|
if details == vec!(PasswordFeedback::TooShort(test_pw_min_length),)));
|
|
|
|
// Test pw len of exact len
|
|
let pw = password_from_random_len(test_pw_min_length);
|
|
let c_status = cutxn
|
|
.credential_primary_set_password(&cust, ct, &pw)
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
assert!(c_status.can_commit);
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
}
|
|
|
|
// Test set of primary account password
|
|
// - fail pw quality checks etc
|
|
// - set correctly.
|
|
|
|
// - setup TOTP
|
|
#[idm_test]
|
|
async fn credential_update_onboarding_create_new_mfa_totp_basic(
|
|
idms: &IdmServer,
|
|
idms_delayed: &mut IdmServerDelayed,
|
|
) {
|
|
let test_pw = "fo3EitierohF9AelaNgiem0Ei6vup4equo1Oogeevaetehah8Tobeengae3Ci0ooh0uki";
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
|
|
let (cust, _) = setup_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
// Setup the PW
|
|
let c_status = cutxn
|
|
.credential_primary_set_password(&cust, ct, test_pw)
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
// Since it's pw only.
|
|
assert!(c_status.can_commit);
|
|
|
|
//
|
|
let c_status = cutxn
|
|
.credential_primary_init_totp(&cust, ct)
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
// Check the status has the token.
|
|
let totp_token: Totp = match c_status.mfaregstate {
|
|
MfaRegStateStatus::TotpCheck(secret) => Some(secret.try_into().unwrap()),
|
|
|
|
_ => None,
|
|
}
|
|
.expect("Unable to retrieve totp token, invalid state.");
|
|
|
|
trace!(?totp_token);
|
|
let chal = totp_token
|
|
.do_totp_duration_from_epoch(&ct)
|
|
.expect("Failed to perform totp step");
|
|
|
|
// Intentionally get it wrong.
|
|
let c_status = cutxn
|
|
.credential_primary_check_totp(&cust, ct, chal + 1, "totp")
|
|
.expect("Failed to update the primary cred totp");
|
|
|
|
assert!(
|
|
matches!(c_status.mfaregstate, MfaRegStateStatus::TotpTryAgain),
|
|
"{:?}",
|
|
c_status.mfaregstate
|
|
);
|
|
|
|
// Check that the user actually put something into the label
|
|
let c_status = cutxn
|
|
.credential_primary_check_totp(&cust, ct, chal, "")
|
|
.expect("Failed to update the primary cred totp");
|
|
|
|
assert!(
|
|
matches!(
|
|
c_status.mfaregstate,
|
|
MfaRegStateStatus::TotpNameTryAgain(ref val) if val.is_empty()
|
|
),
|
|
"{:?}",
|
|
c_status.mfaregstate
|
|
);
|
|
|
|
// Okay, Now they are trying to be smart...
|
|
let c_status = cutxn
|
|
.credential_primary_check_totp(&cust, ct, chal, " ")
|
|
.expect("Failed to update the primary cred totp");
|
|
|
|
assert!(
|
|
matches!(
|
|
c_status.mfaregstate,
|
|
MfaRegStateStatus::TotpNameTryAgain(ref val) if val == " "
|
|
),
|
|
"{:?}",
|
|
c_status.mfaregstate
|
|
);
|
|
|
|
let c_status = cutxn
|
|
.credential_primary_check_totp(&cust, ct, chal, "totp")
|
|
.expect("Failed to update the primary cred totp");
|
|
|
|
assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None));
|
|
assert!(match c_status.primary.as_ref().map(|c| &c.type_) {
|
|
Some(CredentialDetailType::PasswordMfa(totp, _, 0)) => !totp.is_empty(),
|
|
_ => false,
|
|
});
|
|
|
|
{
|
|
let c_status = cutxn
|
|
.credential_primary_init_totp(&cust, ct)
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
// Check the status has the token.
|
|
let totp_token: Totp = match c_status.mfaregstate {
|
|
MfaRegStateStatus::TotpCheck(secret) => Some(secret.try_into().unwrap()),
|
|
_ => None,
|
|
}
|
|
.expect("Unable to retrieve totp token, invalid state.");
|
|
|
|
trace!(?totp_token);
|
|
let chal = totp_token
|
|
.do_totp_duration_from_epoch(&ct)
|
|
.expect("Failed to perform totp step");
|
|
|
|
// They tried to add a second totp under the same name
|
|
let c_status = cutxn
|
|
.credential_primary_check_totp(&cust, ct, chal, "totp")
|
|
.expect("Failed to update the primary cred totp");
|
|
|
|
assert!(
|
|
matches!(
|
|
c_status.mfaregstate,
|
|
MfaRegStateStatus::TotpNameTryAgain(ref val) if val == "totp"
|
|
),
|
|
"{:?}",
|
|
c_status.mfaregstate
|
|
);
|
|
|
|
assert!(cutxn.credential_update_cancel_mfareg(&cust, ct).is_ok())
|
|
}
|
|
|
|
// Should be okay now!
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
// Check it works!
|
|
assert!(
|
|
check_testperson_password_totp(idms, idms_delayed, test_pw, &totp_token, ct)
|
|
.await
|
|
.is_some()
|
|
);
|
|
// No need to test delete of the whole cred, we already did with pw above.
|
|
|
|
// If we remove TOTP, show it reverts back.
|
|
let (cust, _) = renew_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
let c_status = cutxn
|
|
.credential_primary_remove_totp(&cust, ct, "totp")
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None));
|
|
assert!(matches!(
|
|
c_status.primary.as_ref().map(|c| &c.type_),
|
|
Some(CredentialDetailType::Password)
|
|
));
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
// Check it works with totp removed.
|
|
assert!(check_testperson_password(idms, idms_delayed, test_pw, ct)
|
|
.await
|
|
.is_some());
|
|
}
|
|
|
|
// Check sha1 totp.
|
|
#[idm_test]
|
|
async fn credential_update_onboarding_create_new_mfa_totp_sha1(
|
|
idms: &IdmServer,
|
|
idms_delayed: &mut IdmServerDelayed,
|
|
) {
|
|
let test_pw = "fo3EitierohF9AelaNgiem0Ei6vup4equo1Oogeevaetehah8Tobeengae3Ci0ooh0uki";
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
|
|
let (cust, _) = setup_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
// Setup the PW
|
|
let c_status = cutxn
|
|
.credential_primary_set_password(&cust, ct, test_pw)
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
// Since it's pw only.
|
|
assert!(c_status.can_commit);
|
|
|
|
//
|
|
let c_status = cutxn
|
|
.credential_primary_init_totp(&cust, ct)
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
// Check the status has the token.
|
|
let totp_token: Totp = match c_status.mfaregstate {
|
|
MfaRegStateStatus::TotpCheck(secret) => Some(secret.try_into().unwrap()),
|
|
|
|
_ => None,
|
|
}
|
|
.expect("Unable to retrieve totp token, invalid state.");
|
|
|
|
let totp_token = totp_token.downgrade_to_legacy();
|
|
|
|
trace!(?totp_token);
|
|
let chal = totp_token
|
|
.do_totp_duration_from_epoch(&ct)
|
|
.expect("Failed to perform totp step");
|
|
|
|
// Should getn the warn that it's sha1
|
|
let c_status = cutxn
|
|
.credential_primary_check_totp(&cust, ct, chal, "totp")
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
assert!(matches!(
|
|
c_status.mfaregstate,
|
|
MfaRegStateStatus::TotpInvalidSha1
|
|
));
|
|
|
|
// Accept it
|
|
let c_status = cutxn
|
|
.credential_primary_accept_sha1_totp(&cust, ct)
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None));
|
|
assert!(match c_status.primary.as_ref().map(|c| &c.type_) {
|
|
Some(CredentialDetailType::PasswordMfa(totp, _, 0)) => !totp.is_empty(),
|
|
_ => false,
|
|
});
|
|
|
|
// Should be okay now!
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
// Check it works!
|
|
assert!(
|
|
check_testperson_password_totp(idms, idms_delayed, test_pw, &totp_token, ct)
|
|
.await
|
|
.is_some()
|
|
);
|
|
// No need to test delete, we already did with pw above.
|
|
}
|
|
|
|
#[idm_test]
|
|
async fn credential_update_onboarding_create_new_mfa_totp_backup_codes(
|
|
idms: &IdmServer,
|
|
idms_delayed: &mut IdmServerDelayed,
|
|
) {
|
|
let test_pw = "fo3EitierohF9AelaNgiem0Ei6vup4equo1Oogeevaetehah8Tobeengae3Ci0ooh0uki";
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
|
|
let (cust, _) = setup_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
// Setup the PW
|
|
let _c_status = cutxn
|
|
.credential_primary_set_password(&cust, ct, test_pw)
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
// Backup codes are refused to be added because we don't have mfa yet.
|
|
assert!(matches!(
|
|
cutxn.credential_primary_init_backup_codes(&cust, ct),
|
|
Err(OperationError::InvalidState)
|
|
));
|
|
|
|
let c_status = cutxn
|
|
.credential_primary_init_totp(&cust, ct)
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
let totp_token: Totp = match c_status.mfaregstate {
|
|
MfaRegStateStatus::TotpCheck(secret) => Some(secret.try_into().unwrap()),
|
|
_ => None,
|
|
}
|
|
.expect("Unable to retrieve totp token, invalid state.");
|
|
|
|
trace!(?totp_token);
|
|
let chal = totp_token
|
|
.do_totp_duration_from_epoch(&ct)
|
|
.expect("Failed to perform totp step");
|
|
|
|
let c_status = cutxn
|
|
.credential_primary_check_totp(&cust, ct, chal, "totp")
|
|
.expect("Failed to update the primary cred totp");
|
|
|
|
assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None));
|
|
assert!(match c_status.primary.as_ref().map(|c| &c.type_) {
|
|
Some(CredentialDetailType::PasswordMfa(totp, _, 0)) => !totp.is_empty(),
|
|
_ => false,
|
|
});
|
|
|
|
// Now good to go, we need to now add our backup codes.
|
|
// What's the right way to get these back?
|
|
let c_status = cutxn
|
|
.credential_primary_init_backup_codes(&cust, ct)
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
let codes = match c_status.mfaregstate {
|
|
MfaRegStateStatus::BackupCodes(codes) => Some(codes),
|
|
_ => None,
|
|
}
|
|
.expect("Unable to retrieve backupcodes, invalid state.");
|
|
|
|
// Should error because the number is not 0
|
|
debug!("{:?}", c_status.primary.as_ref().map(|c| &c.type_));
|
|
assert!(match c_status.primary.as_ref().map(|c| &c.type_) {
|
|
Some(CredentialDetailType::PasswordMfa(totp, _, 8)) => !totp.is_empty(),
|
|
_ => false,
|
|
});
|
|
|
|
// Should be okay now!
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
let backup_code = codes.iter().next().expect("No codes available");
|
|
|
|
// Check it works!
|
|
assert!(check_testperson_password_backup_code(
|
|
idms,
|
|
idms_delayed,
|
|
test_pw,
|
|
backup_code,
|
|
ct
|
|
)
|
|
.await
|
|
.is_some());
|
|
|
|
// Renew to start the next steps
|
|
let (cust, _) = renew_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
// Only 7 codes left.
|
|
let c_status = cutxn
|
|
.credential_update_status(&cust, ct)
|
|
.expect("Failed to get the current session status.");
|
|
|
|
assert!(match c_status.primary.as_ref().map(|c| &c.type_) {
|
|
Some(CredentialDetailType::PasswordMfa(totp, _, 7)) => !totp.is_empty(),
|
|
_ => false,
|
|
});
|
|
|
|
// If we remove codes, it leaves totp.
|
|
let c_status = cutxn
|
|
.credential_primary_remove_backup_codes(&cust, ct)
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None));
|
|
assert!(match c_status.primary.as_ref().map(|c| &c.type_) {
|
|
Some(CredentialDetailType::PasswordMfa(totp, _, 0)) => !totp.is_empty(),
|
|
_ => false,
|
|
});
|
|
|
|
// Re-add the codes.
|
|
let c_status = cutxn
|
|
.credential_primary_init_backup_codes(&cust, ct)
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
assert!(matches!(
|
|
c_status.mfaregstate,
|
|
MfaRegStateStatus::BackupCodes(_)
|
|
));
|
|
assert!(match c_status.primary.as_ref().map(|c| &c.type_) {
|
|
Some(CredentialDetailType::PasswordMfa(totp, _, 8)) => !totp.is_empty(),
|
|
_ => false,
|
|
});
|
|
|
|
// If we remove totp, it removes codes.
|
|
let c_status = cutxn
|
|
.credential_primary_remove_totp(&cust, ct, "totp")
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None));
|
|
assert!(matches!(
|
|
c_status.primary.as_ref().map(|c| &c.type_),
|
|
Some(CredentialDetailType::Password)
|
|
));
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
}
|
|
|
|
#[idm_test]
|
|
async fn credential_update_onboarding_cancel_inprogress_totp(
|
|
idms: &IdmServer,
|
|
idms_delayed: &mut IdmServerDelayed,
|
|
) {
|
|
let test_pw = "fo3EitierohF9AelaNgiem0Ei6vup4equo1Oogeevaetehah8Tobeengae3Ci0ooh0uki";
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
|
|
let (cust, _) = setup_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
// Setup the PW
|
|
let c_status = cutxn
|
|
.credential_primary_set_password(&cust, ct, test_pw)
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
// Since it's pw only.
|
|
assert!(c_status.can_commit);
|
|
|
|
//
|
|
let c_status = cutxn
|
|
.credential_primary_init_totp(&cust, ct)
|
|
.expect("Failed to update the primary cred totp");
|
|
|
|
// Check the status has the token.
|
|
assert!(c_status.can_commit);
|
|
assert!(matches!(
|
|
c_status.mfaregstate,
|
|
MfaRegStateStatus::TotpCheck(_)
|
|
));
|
|
|
|
let c_status = cutxn
|
|
.credential_update_cancel_mfareg(&cust, ct)
|
|
.expect("Failed to cancel in-flight totp change");
|
|
|
|
assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None));
|
|
assert!(c_status.can_commit);
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
// It's pw only, since we canceled TOTP
|
|
assert!(check_testperson_password(idms, idms_delayed, test_pw, ct)
|
|
.await
|
|
.is_some());
|
|
}
|
|
|
|
// Primary cred must be pw or pwmfa
|
|
|
|
// - setup webauthn
|
|
// - remove webauthn
|
|
// - test multiple webauthn token.
|
|
|
|
async fn create_new_passkey(
|
|
ct: Duration,
|
|
origin: &Url,
|
|
cutxn: &IdmServerCredUpdateTransaction<'_>,
|
|
cust: &CredentialUpdateSessionToken,
|
|
wa: &mut WebauthnAuthenticator<SoftPasskey>,
|
|
) -> CredentialUpdateSessionStatus {
|
|
// Start the registration
|
|
let c_status = cutxn
|
|
.credential_passkey_init(cust, ct)
|
|
.expect("Failed to initiate passkey registration");
|
|
|
|
assert!(c_status.passkeys.is_empty());
|
|
|
|
let passkey_chal = match c_status.mfaregstate {
|
|
MfaRegStateStatus::Passkey(c) => Some(c),
|
|
_ => None,
|
|
}
|
|
.expect("Unable to access passkey challenge, invalid state");
|
|
|
|
let passkey_resp = wa
|
|
.do_registration(origin.clone(), passkey_chal)
|
|
.expect("Failed to create soft passkey");
|
|
|
|
// Finish the registration
|
|
let label = "softtoken".to_string();
|
|
let c_status = cutxn
|
|
.credential_passkey_finish(cust, ct, label, &passkey_resp)
|
|
.expect("Failed to initiate passkey registration");
|
|
|
|
assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None));
|
|
assert!(c_status.primary.as_ref().is_none());
|
|
|
|
// Check we have the passkey
|
|
trace!(?c_status);
|
|
assert_eq!(c_status.passkeys.len(), 1);
|
|
|
|
c_status
|
|
}
|
|
|
|
#[idm_test]
|
|
async fn credential_update_onboarding_create_new_passkey(
|
|
idms: &IdmServer,
|
|
idms_delayed: &mut IdmServerDelayed,
|
|
) {
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
|
|
let (cust, _) = setup_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
let origin = cutxn.get_origin().clone();
|
|
|
|
// Create a soft passkey
|
|
let mut wa = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
|
|
|
let c_status = create_new_passkey(ct, &origin, &cutxn, &cust, &mut wa).await;
|
|
|
|
// Get the UUID of the passkey here.
|
|
let pk_uuid = c_status.passkeys.first().map(|pkd| pkd.uuid).unwrap();
|
|
|
|
// Commit
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
// Do an auth test
|
|
assert!(
|
|
check_testperson_passkey(idms, idms_delayed, &mut wa, origin.clone(), ct)
|
|
.await
|
|
.is_some()
|
|
);
|
|
|
|
// Now test removing the token
|
|
let (cust, _) = renew_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
trace!(?c_status);
|
|
assert!(c_status.primary.is_none());
|
|
assert_eq!(c_status.passkeys.len(), 1);
|
|
|
|
let c_status = cutxn
|
|
.credential_passkey_remove(&cust, ct, pk_uuid)
|
|
.expect("Failed to delete the passkey");
|
|
|
|
trace!(?c_status);
|
|
assert!(c_status.primary.is_none());
|
|
assert!(c_status.passkeys.is_empty());
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
// Must fail now!
|
|
assert!(
|
|
check_testperson_passkey(idms, idms_delayed, &mut wa, origin, ct)
|
|
.await
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[idm_test]
|
|
async fn credential_update_access_denied(
|
|
idms: &IdmServer,
|
|
_idms_delayed: &mut IdmServerDelayed,
|
|
) {
|
|
// Test that if access is denied for a synced account, that the actual action to update
|
|
// the credentials is always denied.
|
|
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
|
|
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
|
|
|
|
let sync_uuid = Uuid::new_v4();
|
|
|
|
let e1 = entry_init!(
|
|
(Attribute::Class, EntryClass::Object.to_value()),
|
|
(Attribute::Class, EntryClass::SyncAccount.to_value()),
|
|
(Attribute::Name, Value::new_iname("test_scim_sync")),
|
|
(Attribute::Uuid, Value::Uuid(sync_uuid)),
|
|
(
|
|
Attribute::Description,
|
|
Value::new_utf8s("A test sync agreement")
|
|
)
|
|
);
|
|
|
|
let e2 = entry_init!(
|
|
(Attribute::Class, EntryClass::Object.to_value()),
|
|
(Attribute::Class, EntryClass::SyncObject.to_value()),
|
|
(Attribute::Class, EntryClass::Account.to_value()),
|
|
(Attribute::Class, EntryClass::PosixAccount.to_value()),
|
|
(Attribute::Class, EntryClass::Person.to_value()),
|
|
(Attribute::SyncParentUuid, Value::Refer(sync_uuid)),
|
|
(Attribute::Name, Value::new_iname("testperson")),
|
|
(Attribute::Uuid, Value::Uuid(TESTPERSON_UUID)),
|
|
(Attribute::Description, Value::new_utf8s("testperson")),
|
|
(Attribute::DisplayName, Value::new_utf8s("testperson"))
|
|
);
|
|
|
|
let ce = CreateEvent::new_internal(vec![e1, e2]);
|
|
let cr = idms_prox_write.qs_write.create(&ce);
|
|
assert!(cr.is_ok());
|
|
|
|
let testperson = idms_prox_write
|
|
.qs_write
|
|
.internal_search_uuid(TESTPERSON_UUID)
|
|
.expect("failed");
|
|
|
|
let cur = idms_prox_write.init_credential_update(
|
|
&InitCredentialUpdateEvent::new_impersonate_entry(testperson),
|
|
ct,
|
|
);
|
|
|
|
idms_prox_write.commit().expect("Failed to commit txn");
|
|
|
|
let (cust, custatus) = cur.expect("Failed to start update");
|
|
|
|
trace!(?custatus);
|
|
|
|
// Destructure to force us to update this test if we change this
|
|
// structure at all.
|
|
let CredentialUpdateSessionStatus {
|
|
spn: _,
|
|
displayname: _,
|
|
ext_cred_portal,
|
|
mfaregstate: _,
|
|
can_commit: _,
|
|
warnings: _,
|
|
primary: _,
|
|
primary_state,
|
|
passkeys: _,
|
|
passkeys_state,
|
|
attested_passkeys: _,
|
|
attested_passkeys_state,
|
|
attested_passkeys_allowed_devices: _,
|
|
unixcred_state,
|
|
unixcred: _,
|
|
sshkeys: _,
|
|
sshkeys_state,
|
|
} = custatus;
|
|
|
|
assert!(matches!(ext_cred_portal, CUExtPortal::Hidden));
|
|
assert!(matches!(primary_state, CredentialState::AccessDeny));
|
|
assert!(matches!(passkeys_state, CredentialState::AccessDeny));
|
|
assert!(matches!(
|
|
attested_passkeys_state,
|
|
CredentialState::AccessDeny
|
|
));
|
|
assert!(matches!(unixcred_state, CredentialState::AccessDeny));
|
|
assert!(matches!(sshkeys_state, CredentialState::AccessDeny));
|
|
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
// let origin = cutxn.get_origin().clone();
|
|
|
|
// Test that any of the primary or passkey update methods fail with access denied.
|
|
|
|
// credential_primary_set_password
|
|
let err = cutxn
|
|
.credential_primary_set_password(&cust, ct, "password")
|
|
.unwrap_err();
|
|
assert!(matches!(err, OperationError::AccessDenied));
|
|
|
|
let err = cutxn
|
|
.credential_unix_set_password(&cust, ct, "password")
|
|
.unwrap_err();
|
|
assert!(matches!(err, OperationError::AccessDenied));
|
|
|
|
let sshkey = SshPublicKey::from_string(SSHKEY_VALID_1).expect("Invalid SSHKEY_VALID_1");
|
|
|
|
let err = cutxn
|
|
.credential_sshkey_add(&cust, ct, "label".to_string(), sshkey)
|
|
.unwrap_err();
|
|
assert!(matches!(err, OperationError::AccessDenied));
|
|
|
|
// credential_primary_init_totp
|
|
let err = cutxn.credential_primary_init_totp(&cust, ct).unwrap_err();
|
|
assert!(matches!(err, OperationError::AccessDenied));
|
|
|
|
// credential_primary_check_totp
|
|
let err = cutxn
|
|
.credential_primary_check_totp(&cust, ct, 0, "totp")
|
|
.unwrap_err();
|
|
assert!(matches!(err, OperationError::AccessDenied));
|
|
|
|
// credential_primary_accept_sha1_totp
|
|
let err = cutxn
|
|
.credential_primary_accept_sha1_totp(&cust, ct)
|
|
.unwrap_err();
|
|
assert!(matches!(err, OperationError::AccessDenied));
|
|
|
|
// credential_primary_remove_totp
|
|
let err = cutxn
|
|
.credential_primary_remove_totp(&cust, ct, "totp")
|
|
.unwrap_err();
|
|
assert!(matches!(err, OperationError::AccessDenied));
|
|
|
|
// credential_primary_init_backup_codes
|
|
let err = cutxn
|
|
.credential_primary_init_backup_codes(&cust, ct)
|
|
.unwrap_err();
|
|
assert!(matches!(err, OperationError::AccessDenied));
|
|
|
|
// credential_primary_remove_backup_codes
|
|
let err = cutxn
|
|
.credential_primary_remove_backup_codes(&cust, ct)
|
|
.unwrap_err();
|
|
assert!(matches!(err, OperationError::AccessDenied));
|
|
|
|
// credential_primary_delete
|
|
let err = cutxn.credential_primary_delete(&cust, ct).unwrap_err();
|
|
assert!(matches!(err, OperationError::AccessDenied));
|
|
|
|
// credential_passkey_init
|
|
let err = cutxn.credential_passkey_init(&cust, ct).unwrap_err();
|
|
assert!(matches!(err, OperationError::AccessDenied));
|
|
|
|
// credential_passkey_finish
|
|
// Can't test because we need a public key response.
|
|
|
|
// credential_passkey_remove
|
|
let err = cutxn
|
|
.credential_passkey_remove(&cust, ct, Uuid::new_v4())
|
|
.unwrap_err();
|
|
assert!(matches!(err, OperationError::AccessDenied));
|
|
|
|
let c_status = cutxn
|
|
.credential_update_status(&cust, ct)
|
|
.expect("Failed to get the current session status.");
|
|
trace!(?c_status);
|
|
assert!(c_status.primary.is_none());
|
|
assert!(c_status.passkeys.is_empty());
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
}
|
|
|
|
// Assert we can't create "just" a password when mfa is required.
|
|
#[idm_test]
|
|
async fn credential_update_account_policy_mfa_required(
|
|
idms: &IdmServer,
|
|
_idms_delayed: &mut IdmServerDelayed,
|
|
) {
|
|
let test_pw = "fo3EitierohF9AelaNgiem0Ei6vup4equo1Oogeevaetehah8Tobeengae3Ci0ooh0uki";
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
|
|
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
|
|
|
|
let modlist = ModifyList::new_purge_and_set(
|
|
Attribute::CredentialTypeMinimum,
|
|
CredentialType::Mfa.into(),
|
|
);
|
|
idms_prox_write
|
|
.qs_write
|
|
.internal_modify_uuid(UUID_IDM_ALL_ACCOUNTS, &modlist)
|
|
.expect("Unable to change default session exp");
|
|
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
// This now will affect all accounts for the next cred update.
|
|
|
|
let (cust, _) = setup_test_session(idms, ct).await;
|
|
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
// Get the credential status - this should tell
|
|
// us the details of the credentials, as well as
|
|
// if they are ready and valid to commit?
|
|
let c_status = cutxn
|
|
.credential_update_status(&cust, ct)
|
|
.expect("Failed to get the current session status.");
|
|
|
|
trace!(?c_status);
|
|
|
|
assert!(c_status.primary.is_none());
|
|
|
|
// Test initially creating a credential.
|
|
// - pw first
|
|
let c_status = cutxn
|
|
.credential_primary_set_password(&cust, ct, test_pw)
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
assert!(!c_status.can_commit);
|
|
assert!(c_status
|
|
.warnings
|
|
.contains(&CredentialUpdateSessionStatusWarnings::MfaRequired));
|
|
// Check reason! Must show "no mfa". We need totp to be added now.
|
|
|
|
let c_status = cutxn
|
|
.credential_primary_init_totp(&cust, ct)
|
|
.expect("Failed to update the primary cred password");
|
|
|
|
// Check the status has the token.
|
|
let totp_token: Totp = match c_status.mfaregstate {
|
|
MfaRegStateStatus::TotpCheck(secret) => Some(secret.try_into().unwrap()),
|
|
|
|
_ => None,
|
|
}
|
|
.expect("Unable to retrieve totp token, invalid state.");
|
|
|
|
trace!(?totp_token);
|
|
let chal = totp_token
|
|
.do_totp_duration_from_epoch(&ct)
|
|
.expect("Failed to perform totp step");
|
|
|
|
let c_status = cutxn
|
|
.credential_primary_check_totp(&cust, ct, chal, "totp")
|
|
.expect("Failed to update the primary cred totp");
|
|
|
|
assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None));
|
|
assert!(match c_status.primary.as_ref().map(|c| &c.type_) {
|
|
Some(CredentialDetailType::PasswordMfa(totp, _, 0)) => !totp.is_empty(),
|
|
_ => false,
|
|
});
|
|
|
|
// Done, can now commit.
|
|
assert!(c_status.can_commit);
|
|
assert!(c_status.warnings.is_empty());
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
// If we remove TOTP, it blocks commit.
|
|
let (cust, _) = renew_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
let c_status = cutxn
|
|
.credential_primary_remove_totp(&cust, ct, "totp")
|
|
.expect("Failed to update the primary cred totp");
|
|
|
|
assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None));
|
|
assert!(matches!(
|
|
c_status.primary.as_ref().map(|c| &c.type_),
|
|
Some(CredentialDetailType::Password)
|
|
));
|
|
|
|
// Delete of the totp forces us back here.
|
|
assert!(!c_status.can_commit);
|
|
assert!(c_status
|
|
.warnings
|
|
.contains(&CredentialUpdateSessionStatusWarnings::MfaRequired));
|
|
|
|
// Passkeys satisfy the policy though
|
|
let c_status = cutxn
|
|
.credential_primary_delete(&cust, ct)
|
|
.expect("Failed to delete the primary credential");
|
|
assert!(c_status.primary.is_none());
|
|
|
|
let origin = cutxn.get_origin().clone();
|
|
let mut wa = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
|
|
|
let c_status = create_new_passkey(ct, &origin, &cutxn, &cust, &mut wa).await;
|
|
|
|
assert!(c_status.can_commit);
|
|
assert!(c_status.warnings.is_empty());
|
|
assert_eq!(c_status.passkeys.len(), 1);
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
}
|
|
|
|
#[idm_test]
|
|
async fn credential_update_account_policy_passkey_required(
|
|
idms: &IdmServer,
|
|
_idms_delayed: &mut IdmServerDelayed,
|
|
) {
|
|
let test_pw = "fo3EitierohF9AelaNgiem0Ei6vup4equo1Oogeevaetehah8Tobeengae3Ci0ooh0uki";
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
|
|
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
|
|
|
|
let modlist = ModifyList::new_purge_and_set(
|
|
Attribute::CredentialTypeMinimum,
|
|
CredentialType::Passkey.into(),
|
|
);
|
|
idms_prox_write
|
|
.qs_write
|
|
.internal_modify_uuid(UUID_IDM_ALL_ACCOUNTS, &modlist)
|
|
.expect("Unable to change default session exp");
|
|
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
// This now will affect all accounts for the next cred update.
|
|
|
|
let (cust, _) = setup_test_session(idms, ct).await;
|
|
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
// Get the credential status - this should tell
|
|
// us the details of the credentials, as well as
|
|
// if they are ready and valid to commit?
|
|
let c_status = cutxn
|
|
.credential_update_status(&cust, ct)
|
|
.expect("Failed to get the current session status.");
|
|
|
|
trace!(?c_status);
|
|
assert!(c_status.primary.is_none());
|
|
assert!(matches!(
|
|
c_status.primary_state,
|
|
CredentialState::PolicyDeny
|
|
));
|
|
|
|
let err = cutxn
|
|
.credential_primary_set_password(&cust, ct, test_pw)
|
|
.unwrap_err();
|
|
assert!(matches!(err, OperationError::AccessDenied));
|
|
|
|
let origin = cutxn.get_origin().clone();
|
|
let mut wa = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
|
|
|
let c_status = create_new_passkey(ct, &origin, &cutxn, &cust, &mut wa).await;
|
|
|
|
assert!(c_status.can_commit);
|
|
assert!(c_status.warnings.is_empty());
|
|
assert_eq!(c_status.passkeys.len(), 1);
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
}
|
|
|
|
// Attested passkey types
|
|
|
|
#[idm_test]
|
|
async fn credential_update_account_policy_attested_passkey_required(
|
|
idms: &IdmServer,
|
|
idms_delayed: &mut IdmServerDelayed,
|
|
) {
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
|
|
// Create the attested soft token we will use in this test.
|
|
let (soft_token_valid, ca_root) = SoftToken::new(true).unwrap();
|
|
let mut wa_token_valid = WebauthnAuthenticator::new(soft_token_valid);
|
|
|
|
// Create it's associated policy.
|
|
let mut att_ca_builder = AttestationCaListBuilder::new();
|
|
att_ca_builder
|
|
.insert_device_x509(
|
|
ca_root,
|
|
softtoken::AAGUID,
|
|
"softtoken".to_string(),
|
|
Default::default(),
|
|
)
|
|
.unwrap();
|
|
let att_ca_list = att_ca_builder.build();
|
|
|
|
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
|
|
|
|
let modlist = ModifyList::new_purge_and_set(
|
|
Attribute::WebauthnAttestationCaList,
|
|
Value::WebauthnAttestationCaList(att_ca_list),
|
|
);
|
|
idms_prox_write
|
|
.qs_write
|
|
.internal_modify_uuid(UUID_IDM_ALL_ACCOUNTS, &modlist)
|
|
.expect("Unable to change webauthn attestation policy");
|
|
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
|
|
// Create the invalid tokens
|
|
let (soft_token_invalid, _) = SoftToken::new(true).unwrap();
|
|
let mut wa_token_invalid = WebauthnAuthenticator::new(soft_token_invalid);
|
|
|
|
let mut wa_passkey_invalid = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
|
|
|
// Setup the cred update session.
|
|
|
|
let (cust, _) = setup_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
let origin = cutxn.get_origin().clone();
|
|
|
|
// Our status needs the correct device names for UI hinting.
|
|
let c_status = cutxn
|
|
.credential_update_status(&cust, ct)
|
|
.expect("Failed to get the current session status.");
|
|
|
|
trace!(?c_status);
|
|
assert!(c_status.attested_passkeys.is_empty());
|
|
assert_eq!(
|
|
c_status.attested_passkeys_allowed_devices,
|
|
vec!["softtoken".to_string()]
|
|
);
|
|
|
|
// -------------------------------------------------------
|
|
// Unable to add an passkey when attestation is requested.
|
|
let err = cutxn.credential_passkey_init(&cust, ct).unwrap_err();
|
|
assert!(matches!(err, OperationError::AccessDenied));
|
|
|
|
// -------------------------------------------------------
|
|
// Reject a credential that lacks attestation
|
|
let c_status = cutxn
|
|
.credential_attested_passkey_init(&cust, ct)
|
|
.expect("Failed to initiate attested passkey registration");
|
|
|
|
let passkey_chal = match c_status.mfaregstate {
|
|
MfaRegStateStatus::AttestedPasskey(c) => Some(c),
|
|
_ => None,
|
|
}
|
|
.expect("Unable to access passkey challenge, invalid state");
|
|
|
|
let passkey_resp = wa_passkey_invalid
|
|
.do_registration(origin.clone(), passkey_chal)
|
|
.expect("Failed to create soft passkey");
|
|
|
|
// Finish the registration
|
|
let label = "softtoken".to_string();
|
|
let err = cutxn
|
|
.credential_attested_passkey_finish(&cust, ct, label, &passkey_resp)
|
|
.unwrap_err();
|
|
|
|
assert!(matches!(
|
|
err,
|
|
OperationError::CU0001WebauthnAttestationNotTrusted
|
|
));
|
|
|
|
// -------------------------------------------------------
|
|
// Reject a credential with wrong CA / correct aaguid
|
|
let c_status = cutxn
|
|
.credential_attested_passkey_init(&cust, ct)
|
|
.expect("Failed to initiate attested passkey registration");
|
|
|
|
let passkey_chal = match c_status.mfaregstate {
|
|
MfaRegStateStatus::AttestedPasskey(c) => Some(c),
|
|
_ => None,
|
|
}
|
|
.expect("Unable to access passkey challenge, invalid state");
|
|
|
|
let passkey_resp = wa_token_invalid
|
|
.do_registration(origin.clone(), passkey_chal)
|
|
.expect("Failed to create soft passkey");
|
|
|
|
// Finish the registration
|
|
let label = "softtoken".to_string();
|
|
let err = cutxn
|
|
.credential_attested_passkey_finish(&cust, ct, label, &passkey_resp)
|
|
.unwrap_err();
|
|
|
|
assert!(matches!(
|
|
err,
|
|
OperationError::CU0001WebauthnAttestationNotTrusted
|
|
));
|
|
|
|
// -------------------------------------------------------
|
|
// Accept credential with correct CA/aaguid
|
|
let c_status = cutxn
|
|
.credential_attested_passkey_init(&cust, ct)
|
|
.expect("Failed to initiate attested passkey registration");
|
|
|
|
let passkey_chal = match c_status.mfaregstate {
|
|
MfaRegStateStatus::AttestedPasskey(c) => Some(c),
|
|
_ => None,
|
|
}
|
|
.expect("Unable to access passkey challenge, invalid state");
|
|
|
|
let passkey_resp = wa_token_valid
|
|
.do_registration(origin.clone(), passkey_chal)
|
|
.expect("Failed to create soft passkey");
|
|
|
|
// Finish the registration
|
|
let label = "softtoken".to_string();
|
|
let c_status = cutxn
|
|
.credential_attested_passkey_finish(&cust, ct, label, &passkey_resp)
|
|
.expect("Failed to initiate passkey registration");
|
|
|
|
assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None));
|
|
trace!(?c_status);
|
|
assert_eq!(c_status.attested_passkeys.len(), 1);
|
|
|
|
let pk_uuid = c_status
|
|
.attested_passkeys
|
|
.first()
|
|
.map(|pkd| pkd.uuid)
|
|
.unwrap();
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
// Assert that auth works.
|
|
assert!(check_testperson_passkey(
|
|
idms,
|
|
idms_delayed,
|
|
&mut wa_token_valid,
|
|
origin.clone(),
|
|
ct
|
|
)
|
|
.await
|
|
.is_some());
|
|
|
|
// Remove attested passkey works.
|
|
let (cust, _) = renew_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
trace!(?c_status);
|
|
assert!(c_status.primary.is_none());
|
|
assert!(c_status.passkeys.is_empty());
|
|
assert_eq!(c_status.attested_passkeys.len(), 1);
|
|
|
|
let c_status = cutxn
|
|
.credential_attested_passkey_remove(&cust, ct, pk_uuid)
|
|
.expect("Failed to delete the attested passkey");
|
|
|
|
trace!(?c_status);
|
|
assert!(c_status.primary.is_none());
|
|
assert!(c_status.passkeys.is_empty());
|
|
assert!(c_status.attested_passkeys.is_empty());
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
// Must fail now!
|
|
assert!(
|
|
check_testperson_passkey(idms, idms_delayed, &mut wa_token_valid, origin, ct)
|
|
.await
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[idm_test(audit = 1)]
|
|
async fn credential_update_account_policy_attested_passkey_changed(
|
|
idms: &IdmServer,
|
|
idms_delayed: &mut IdmServerDelayed,
|
|
idms_audit: &mut IdmServerAudit,
|
|
) {
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
|
|
// Setup the policy.
|
|
let (soft_token_1, ca_root_1) = SoftToken::new(true).unwrap();
|
|
let mut wa_token_1 = WebauthnAuthenticator::new(soft_token_1);
|
|
|
|
let (_soft_token_2, ca_root_2) = SoftToken::new(true).unwrap();
|
|
|
|
let mut att_ca_builder = AttestationCaListBuilder::new();
|
|
att_ca_builder
|
|
.insert_device_x509(
|
|
ca_root_1.clone(),
|
|
softtoken::AAGUID,
|
|
"softtoken_1".to_string(),
|
|
Default::default(),
|
|
)
|
|
.unwrap();
|
|
let att_ca_list = att_ca_builder.build();
|
|
|
|
trace!(?att_ca_list);
|
|
|
|
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
|
|
|
|
let modlist = ModifyList::new_purge_and_set(
|
|
Attribute::WebauthnAttestationCaList,
|
|
Value::WebauthnAttestationCaList(att_ca_list),
|
|
);
|
|
idms_prox_write
|
|
.qs_write
|
|
.internal_modify_uuid(UUID_IDM_ALL_ACCOUNTS, &modlist)
|
|
.expect("Unable to change webauthn attestation policy");
|
|
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
|
|
// Setup the policy for later that lacks token 2.
|
|
let mut att_ca_builder = AttestationCaListBuilder::new();
|
|
att_ca_builder
|
|
.insert_device_x509(
|
|
ca_root_2,
|
|
softtoken::AAGUID,
|
|
"softtoken_2".to_string(),
|
|
Default::default(),
|
|
)
|
|
.unwrap();
|
|
let att_ca_list_post = att_ca_builder.build();
|
|
|
|
// Enroll the attested keys
|
|
let (cust, _) = setup_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
let origin = cutxn.get_origin().clone();
|
|
|
|
// -------------------------------------------------------
|
|
let c_status = cutxn
|
|
.credential_attested_passkey_init(&cust, ct)
|
|
.expect("Failed to initiate attested passkey registration");
|
|
|
|
let passkey_chal = match c_status.mfaregstate {
|
|
MfaRegStateStatus::AttestedPasskey(c) => Some(c),
|
|
_ => None,
|
|
}
|
|
.expect("Unable to access passkey challenge, invalid state");
|
|
|
|
let passkey_resp = wa_token_1
|
|
.do_registration(origin.clone(), passkey_chal)
|
|
.expect("Failed to create soft passkey");
|
|
|
|
// Finish the registration
|
|
let label = "softtoken".to_string();
|
|
let c_status = cutxn
|
|
.credential_attested_passkey_finish(&cust, ct, label, &passkey_resp)
|
|
.expect("Failed to initiate passkey registration");
|
|
|
|
assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None));
|
|
trace!(?c_status);
|
|
assert_eq!(c_status.attested_passkeys.len(), 1);
|
|
|
|
// -------------------------------------------------------
|
|
// Commit
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
// Check auth works
|
|
assert!(
|
|
check_testperson_passkey(idms, idms_delayed, &mut wa_token_1, origin.clone(), ct)
|
|
.await
|
|
.is_some()
|
|
);
|
|
|
|
// Change policy
|
|
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
|
|
|
|
let modlist = ModifyList::new_purge_and_set(
|
|
Attribute::WebauthnAttestationCaList,
|
|
Value::WebauthnAttestationCaList(att_ca_list_post),
|
|
);
|
|
idms_prox_write
|
|
.qs_write
|
|
.internal_modify_uuid(UUID_IDM_ALL_ACCOUNTS, &modlist)
|
|
.expect("Unable to change webauthn attestation policy");
|
|
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
|
|
// Auth fail
|
|
assert!(
|
|
check_testperson_passkey(idms, idms_delayed, &mut wa_token_1, origin.clone(), ct)
|
|
.await
|
|
.is_none()
|
|
);
|
|
|
|
// This gives an auth denied because the attested passkey still exists but it no longer
|
|
// meets criteria.
|
|
match idms_audit.audit_rx().try_recv() {
|
|
Ok(AuditEvent::AuthenticationDenied { .. }) => {}
|
|
_ => panic!("Oh no"),
|
|
}
|
|
|
|
// Update creds
|
|
let (cust, _) = renew_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
// Invalid key removed
|
|
let c_status = cutxn
|
|
.credential_update_status(&cust, ct)
|
|
.expect("Failed to get the current session status.");
|
|
|
|
trace!(?c_status);
|
|
assert!(c_status.attested_passkeys.is_empty());
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
// Auth fail
|
|
assert!(
|
|
check_testperson_passkey(idms, idms_delayed, &mut wa_token_1, origin.clone(), ct)
|
|
.await
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
// Test that when attestation policy is removed, the apk downgrades to passkey and still works.
|
|
#[idm_test]
|
|
async fn credential_update_account_policy_attested_passkey_downgrade(
|
|
idms: &IdmServer,
|
|
idms_delayed: &mut IdmServerDelayed,
|
|
) {
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
|
|
// Setup the policy.
|
|
let (soft_token_1, ca_root_1) = SoftToken::new(true).unwrap();
|
|
let mut wa_token_1 = WebauthnAuthenticator::new(soft_token_1);
|
|
|
|
let mut att_ca_builder = AttestationCaListBuilder::new();
|
|
att_ca_builder
|
|
.insert_device_x509(
|
|
ca_root_1.clone(),
|
|
softtoken::AAGUID,
|
|
"softtoken_1".to_string(),
|
|
Default::default(),
|
|
)
|
|
.unwrap();
|
|
let att_ca_list = att_ca_builder.build();
|
|
|
|
trace!(?att_ca_list);
|
|
|
|
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
|
|
|
|
let modlist = ModifyList::new_purge_and_set(
|
|
Attribute::WebauthnAttestationCaList,
|
|
Value::WebauthnAttestationCaList(att_ca_list),
|
|
);
|
|
idms_prox_write
|
|
.qs_write
|
|
.internal_modify_uuid(UUID_IDM_ALL_ACCOUNTS, &modlist)
|
|
.expect("Unable to change webauthn attestation policy");
|
|
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
|
|
// Enroll the attested keys
|
|
let (cust, _) = setup_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
let origin = cutxn.get_origin().clone();
|
|
|
|
// -------------------------------------------------------
|
|
let c_status = cutxn
|
|
.credential_attested_passkey_init(&cust, ct)
|
|
.expect("Failed to initiate attested passkey registration");
|
|
|
|
let passkey_chal = match c_status.mfaregstate {
|
|
MfaRegStateStatus::AttestedPasskey(c) => Some(c),
|
|
_ => None,
|
|
}
|
|
.expect("Unable to access passkey challenge, invalid state");
|
|
|
|
let passkey_resp = wa_token_1
|
|
.do_registration(origin.clone(), passkey_chal)
|
|
.expect("Failed to create soft passkey");
|
|
|
|
// Finish the registration
|
|
let label = "softtoken".to_string();
|
|
let c_status = cutxn
|
|
.credential_attested_passkey_finish(&cust, ct, label, &passkey_resp)
|
|
.expect("Failed to initiate passkey registration");
|
|
|
|
assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None));
|
|
trace!(?c_status);
|
|
assert_eq!(c_status.attested_passkeys.len(), 1);
|
|
|
|
// -------------------------------------------------------
|
|
// Commit
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
// Check auth works
|
|
assert!(
|
|
check_testperson_passkey(idms, idms_delayed, &mut wa_token_1, origin.clone(), ct)
|
|
.await
|
|
.is_some()
|
|
);
|
|
|
|
// Change policy
|
|
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
|
|
|
|
let modlist = ModifyList::new_purge(Attribute::WebauthnAttestationCaList);
|
|
idms_prox_write
|
|
.qs_write
|
|
.internal_modify_uuid(UUID_IDM_ALL_ACCOUNTS, &modlist)
|
|
.expect("Unable to change webauthn attestation policy");
|
|
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
|
|
// Auth still passes, key was downgraded.
|
|
assert!(
|
|
check_testperson_passkey(idms, idms_delayed, &mut wa_token_1, origin.clone(), ct)
|
|
.await
|
|
.is_some()
|
|
);
|
|
|
|
// Show it still exists, but can only be deleted now.
|
|
let (cust, _) = renew_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
let c_status = cutxn
|
|
.credential_update_status(&cust, ct)
|
|
.expect("Failed to get the current session status.");
|
|
|
|
trace!(?c_status);
|
|
assert_eq!(c_status.attested_passkeys.len(), 1);
|
|
assert!(matches!(
|
|
c_status.attested_passkeys_state,
|
|
CredentialState::DeleteOnly
|
|
));
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
}
|
|
|
|
#[idm_test]
|
|
async fn credential_update_unix_password(
|
|
idms: &IdmServer,
|
|
_idms_delayed: &mut IdmServerDelayed,
|
|
) {
|
|
let test_pw = "fo3EitierohF9AelaNgiem0Ei6vup4equo1Oogeevaetehah8Tobeengae3Ci0ooh0uki";
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
|
|
let (cust, _) = setup_test_session(idms, ct).await;
|
|
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
// Get the credential status - this should tell
|
|
// us the details of the credentials, as well as
|
|
// if they are ready and valid to commit?
|
|
let c_status = cutxn
|
|
.credential_update_status(&cust, ct)
|
|
.expect("Failed to get the current session status.");
|
|
|
|
trace!(?c_status);
|
|
|
|
assert!(c_status.unixcred.is_none());
|
|
|
|
// Test initially creating a credential.
|
|
// - pw first
|
|
let c_status = cutxn
|
|
.credential_unix_set_password(&cust, ct, test_pw)
|
|
.expect("Failed to update the unix cred password");
|
|
|
|
assert!(c_status.can_commit);
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
// Check it works!
|
|
assert!(check_testperson_unix_password(idms, test_pw, ct)
|
|
.await
|
|
.is_some());
|
|
|
|
// Test deleting the pw
|
|
let (cust, _) = renew_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
let c_status = cutxn
|
|
.credential_update_status(&cust, ct)
|
|
.expect("Failed to get the current session status.");
|
|
trace!(?c_status);
|
|
assert!(c_status.unixcred.is_some());
|
|
|
|
let c_status = cutxn
|
|
.credential_unix_delete(&cust, ct)
|
|
.expect("Failed to delete the unix cred");
|
|
trace!(?c_status);
|
|
assert!(c_status.unixcred.is_none());
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
|
|
// Must fail now!
|
|
assert!(check_testperson_unix_password(idms, test_pw, ct)
|
|
.await
|
|
.is_none());
|
|
}
|
|
|
|
#[idm_test]
|
|
async fn credential_update_sshkeys(idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed) {
|
|
let sshkey_valid_1 =
|
|
SshPublicKey::from_string(SSHKEY_VALID_1).expect("Invalid SSHKEY_VALID_1");
|
|
let sshkey_valid_2 =
|
|
SshPublicKey::from_string(SSHKEY_VALID_2).expect("Invalid SSHKEY_VALID_2");
|
|
|
|
assert!(SshPublicKey::from_string(SSHKEY_INVALID).is_err());
|
|
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
let (cust, _) = setup_test_session(idms, ct).await;
|
|
let cutxn = idms.cred_update_transaction().await.unwrap();
|
|
|
|
let c_status = cutxn
|
|
.credential_update_status(&cust, ct)
|
|
.expect("Failed to get the current session status.");
|
|
|
|
trace!(?c_status);
|
|
|
|
assert!(c_status.sshkeys.is_empty());
|
|
|
|
// Reject empty str key label
|
|
let result = cutxn.credential_sshkey_add(&cust, ct, "".to_string(), sshkey_valid_1.clone());
|
|
assert!(matches!(result, Err(OperationError::InvalidLabel)));
|
|
|
|
// Reject invalid name label.
|
|
let result =
|
|
cutxn.credential_sshkey_add(&cust, ct, "🚛".to_string(), sshkey_valid_1.clone());
|
|
assert!(matches!(result, Err(OperationError::InvalidLabel)));
|
|
|
|
// Remove non-existante
|
|
let result = cutxn.credential_sshkey_remove(&cust, ct, "key1");
|
|
assert!(matches!(result, Err(OperationError::NoMatchingEntries)));
|
|
|
|
// Add a valid key.
|
|
let c_status = cutxn
|
|
.credential_sshkey_add(&cust, ct, "key1".to_string(), sshkey_valid_1.clone())
|
|
.expect("Failed to add sshkey_valid_1");
|
|
|
|
trace!(?c_status);
|
|
assert_eq!(c_status.sshkeys.len(), 1);
|
|
assert!(c_status.sshkeys.contains_key("key1"));
|
|
|
|
// Add a second valid key.
|
|
let c_status = cutxn
|
|
.credential_sshkey_add(&cust, ct, "key2".to_string(), sshkey_valid_2.clone())
|
|
.expect("Failed to add sshkey_valid_2");
|
|
|
|
trace!(?c_status);
|
|
assert_eq!(c_status.sshkeys.len(), 2);
|
|
assert!(c_status.sshkeys.contains_key("key1"));
|
|
assert!(c_status.sshkeys.contains_key("key2"));
|
|
|
|
// Remove a key (check second key untouched)
|
|
let c_status = cutxn
|
|
.credential_sshkey_remove(&cust, ct, "key2")
|
|
.expect("Failed to remove sshkey_valid_2");
|
|
|
|
trace!(?c_status);
|
|
assert_eq!(c_status.sshkeys.len(), 1);
|
|
assert!(c_status.sshkeys.contains_key("key1"));
|
|
|
|
// Reject duplicate key label
|
|
let result =
|
|
cutxn.credential_sshkey_add(&cust, ct, "key1".to_string(), sshkey_valid_2.clone());
|
|
assert!(matches!(result, Err(OperationError::DuplicateLabel)));
|
|
|
|
// Reject duplicate key
|
|
let result =
|
|
cutxn.credential_sshkey_add(&cust, ct, "key2".to_string(), sshkey_valid_1.clone());
|
|
assert!(matches!(result, Err(OperationError::DuplicateKey)));
|
|
|
|
drop(cutxn);
|
|
commit_session(idms, ct, cust).await;
|
|
}
|
|
}
|