mirror of
https://github.com/kanidm/kanidm.git
synced 2025-04-22 02:05:38 +02:00
Removes the protected plugin into an access control module so that it's outputs can be properly represented in effective access checks.
4825 lines
177 KiB
Rust
4825 lines
177 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 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::zxcvbn(cleartext, related_inputs).map_err(|e| {
|
|
admin_error!("zxcvbn check failure (password empty?) {:?}", e);
|
|
// Return some generic feedback when the password is this bad.
|
|
PasswordQuality::Feedback(vec![
|
|
PasswordFeedback::UseAFewWordsAvoidCommonPhrases,
|
|
PasswordFeedback::AddAnotherWordOrTwo,
|
|
PasswordFeedback::NoNeedForSymbolsDigitsOrUppercaseLetters,
|
|
])
|
|
})?;
|
|
|
|
// PW's should always be enforced as strong as possible.
|
|
if entropy.score() < 4 {
|
|
// 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()
|
|
.as_ref()
|
|
.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 == ""
|
|
),
|
|
"{:?}",
|
|
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;
|
|
}
|
|
}
|