kanidm unixd mfa capabilities ()

Improve the support for the resolver to support MFA options with pam. This enables async task spawning and cancelation via the resolver backend as well. 

Co-authored-by: David Mulder <dmulder@samba.org>
This commit is contained in:
Firstyear 2024-03-28 11:17:21 +10:00 committed by GitHub
parent 03ce2a0c32
commit c09daa4643
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 265 additions and 87 deletions
server/core/src/https
unix_integration

View file

@ -3181,7 +3181,12 @@ pub(crate) fn route_setup(state: ServerState) -> Router<ServerState> {
.route("/v1/group/:id/_unix/_token", get(group_id_unix_token_get))
.route("/v1/group/:id/_unix", post(group_id_unix_post))
.route("/v1/group", get(group_get).post(group_post))
.route("/v1/group/:id", get(group_id_get).patch(group_id_patch).delete(group_id_delete))
.route(
"/v1/group/:id",
get(group_id_get)
.patch(group_id_patch)
.delete(group_id_delete),
)
.route(
"/v1/group/:id/_attr/:attr",
delete(group_id_attr_delete)

View file

@ -53,6 +53,9 @@ use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::fmt;
use tracing_subscriber::prelude::*;
use std::thread;
use std::time::Duration;
pub fn get_cfg() -> Result<KanidmUnixdConfig, PamResultCode> {
KanidmUnixdConfig::new()
.read_options_from_optional_config(DEFAULT_CONFIG_PATH)
@ -106,6 +109,38 @@ pub struct PamKanidm;
pam_hooks!(PamKanidm);
macro_rules! match_sm_auth_client_response {
($expr:expr, $opts:ident, $($pat:pat => $result:expr),*) => {
match $expr {
Ok(r) => match r {
$($pat => $result),*
ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Success) => {
return PamResultCode::PAM_SUCCESS;
}
ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Denied) => {
return PamResultCode::PAM_AUTH_ERR;
}
ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Unknown) => {
if $opts.ignore_unknown_user {
return PamResultCode::PAM_IGNORE;
} else {
return PamResultCode::PAM_USER_UNKNOWN;
}
}
_ => {
// unexpected response.
error!(err = ?r, "PAM_IGNORE, unexpected resolver response");
return PamResultCode::PAM_IGNORE;
}
},
Err(err) => {
error!(?err, "PAM_IGNORE");
return PamResultCode::PAM_IGNORE;
}
}
}
}
impl PamHooks for PamKanidm {
fn acct_mgmt(pamh: &PamHandle, args: Vec<&CStr>, _flags: PamFlag) -> PamResultCode {
let opts = match Options::try_from(&args) {
@ -241,85 +276,129 @@ impl PamHooks for PamKanidm {
let mut req = ClientRequest::PamAuthenticateInit(account_id);
loop {
match daemon_client.call_and_wait(&req, timeout) {
Ok(r) => match r {
ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Success) => {
return PamResultCode::PAM_SUCCESS;
}
ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Denied) => {
return PamResultCode::PAM_AUTH_ERR;
}
ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Unknown) => {
if opts.ignore_unknown_user {
return PamResultCode::PAM_IGNORE;
} else {
return PamResultCode::PAM_USER_UNKNOWN;
}
}
ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Password) => {
let mut consume_authtok = None;
// Swap the authtok out with a None, so it can only be consumed once.
// If it's already been swapped, we are just swapping two null pointers
// here effectively.
std::mem::swap(&mut authtok, &mut consume_authtok);
let cred = if let Some(cred) = consume_authtok {
cred
} else {
match conv.send(PAM_PROMPT_ECHO_OFF, "Password: ") {
Ok(password) => match password {
Some(cred) => cred,
None => {
debug!("no password");
return PamResultCode::PAM_CRED_INSUFFICIENT;
}
},
Err(err) => {
debug!("unable to get password");
return err;
match_sm_auth_client_response!(daemon_client.call_and_wait(&req, timeout), opts,
ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Password) => {
let mut consume_authtok = None;
// Swap the authtok out with a None, so it can only be consumed once.
// If it's already been swapped, we are just swapping two null pointers
// here effectively.
std::mem::swap(&mut authtok, &mut consume_authtok);
let cred = if let Some(cred) = consume_authtok {
cred
} else {
match conv.send(PAM_PROMPT_ECHO_OFF, "Password: ") {
Ok(password) => match password {
Some(cred) => cred,
None => {
debug!("no password");
return PamResultCode::PAM_CRED_INSUFFICIENT;
}
}
};
// Now setup the request for the next loop.
timeout = cfg.unix_sock_timeout;
req = ClientRequest::PamAuthenticateStep(PamAuthRequest::Password { cred });
continue;
}
ClientResponse::PamAuthenticateStepResponse(
PamAuthResponse::DeviceAuthorizationGrant { data },
) => {
let msg = match &data.message {
Some(msg) => msg.clone(),
None => format!("Using a browser on another device, visit:\n{}\nAnd enter the code:\n{}",
data.verification_uri, data.user_code)
};
match conv.send(PAM_TEXT_INFO, &msg) {
Ok(_) => {}
},
Err(err) => {
if opts.debug {
println!("Message prompt failed");
}
debug!("unable to get password");
return err;
}
}
};
timeout = u64::from(data.expires_in);
req = ClientRequest::PamAuthenticateStep(
PamAuthRequest::DeviceAuthorizationGrant { data },
);
continue;
}
_ => {
// unexpected response.
error!(err = ?r, "PAM_IGNORE, unexpected resolver response");
return PamResultCode::PAM_IGNORE;
}
// Now setup the request for the next loop.
timeout = cfg.unix_sock_timeout;
req = ClientRequest::PamAuthenticateStep(PamAuthRequest::Password { cred });
continue;
},
Err(err) => {
error!(?err, "PAM_IGNORE");
return PamResultCode::PAM_IGNORE;
ClientResponse::PamAuthenticateStepResponse(
PamAuthResponse::DeviceAuthorizationGrant { data },
) => {
let msg = match &data.message {
Some(msg) => msg.clone(),
None => format!("Using a browser on another device, visit:\n{}\nAnd enter the code:\n{}",
data.verification_uri, data.user_code)
};
match conv.send(PAM_TEXT_INFO, &msg) {
Ok(_) => {}
Err(err) => {
if opts.debug {
println!("Message prompt failed");
}
return err;
}
}
timeout = u64::from(data.expires_in);
req = ClientRequest::PamAuthenticateStep(
PamAuthRequest::DeviceAuthorizationGrant { data },
);
continue;
},
ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::MFACode {
msg,
}) => {
match conv.send(PAM_TEXT_INFO, &msg) {
Ok(_) => {}
Err(err) => {
if opts.debug {
println!("Message prompt failed");
}
return err;
}
}
let cred = match conv.send(PAM_PROMPT_ECHO_OFF, "Code") {
Ok(password) => match password {
Some(cred) => cred,
None => {
debug!("no mfa code");
return PamResultCode::PAM_CRED_INSUFFICIENT;
}
},
Err(err) => {
debug!("unable to get mfa code");
return err;
}
};
// Now setup the request for the next loop.
timeout = cfg.unix_sock_timeout;
req = ClientRequest::PamAuthenticateStep(PamAuthRequest::MFACode {
cred,
});
continue;
},
ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::MFAPoll {
msg,
polling_interval,
}) => {
match conv.send(PAM_TEXT_INFO, &msg) {
Ok(_) => {}
Err(err) => {
if opts.debug {
println!("Message prompt failed");
}
return err;
}
}
loop {
thread::sleep(Duration::from_secs(polling_interval.into()));
timeout = cfg.unix_sock_timeout;
req = ClientRequest::PamAuthenticateStep(PamAuthRequest::MFAPoll);
// Counter intuitive, but we don't need a max poll attempts here because
// if the resolver goes away, then this will error on the sock and
// will shutdown. This allows the resolver to dynamically extend the
// timeout if needed, and removes logic from the front end.
match_sm_auth_client_response!(
daemon_client.call_and_wait(&req, timeout), opts,
ClientResponse::PamAuthenticateStepResponse(
PamAuthResponse::MFAPollWait,
) => {
// Continue polling if the daemon says to wait
continue;
}
);
}
}
}
);
} // while true, continue calling PamAuthenticateStep until we get a decision.
}

View file

@ -202,6 +202,10 @@ async fn handle_client(
let mut reqs = Framed::new(sock, ClientCodec);
let mut pam_auth_session_state = None;
// Setup a broadcast channel so that if we have an unexpected disconnection, we can
// tell consumers to stop work.
let (shutdown_tx, _shutdown_rx) = broadcast::channel(1);
trace!("Waiting for requests ...");
while let Some(Ok(req)) = reqs.next().await {
let resp = match req {
@ -295,7 +299,10 @@ async fn handle_client(
}
None => {
match cachelayer
.pam_account_authenticate_init(account_id.as_str())
.pam_account_authenticate_init(
account_id.as_str(),
shutdown_tx.subscribe(),
)
.await
{
Ok((auth_session, pam_auth_response)) => {
@ -407,6 +414,14 @@ async fn handle_client(
debug!("flushed response!");
}
// Signal any tasks that they need to stop.
if let Err(shutdown_err) = shutdown_tx.send(()) {
warn!(
?shutdown_err,
"Unable to signal tasks to stop, they will naturally timeout instead."
)
}
// Disconnect them
debug!("Disconnecting client ...");
Ok(())

View file

@ -2,6 +2,7 @@ use crate::db::KeyStoreTxn;
use crate::unix_proto::{DeviceAuthorizationResponse, PamAuthRequest, PamAuthResponse};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use uuid::Uuid;
pub use kanidm_hsm_crypto as tpm;
@ -63,11 +64,35 @@ pub struct UserToken {
pub enum AuthCredHandler {
Password,
DeviceAuthorizationGrant,
/// Additional data required by the provider to complete the
/// authentication, but not required by PAM
///
/// Sadly due to how this is passed around we can't make this a
/// generic associated type, else it would have to leak up to the
/// daemon.
///
/// ⚠️ TODO: Optimally this should actually be a tokio oneshot receiver
/// with the decision from a task that is spawned.
MFA {
data: Vec<String>,
},
}
pub enum AuthRequest {
Password,
DeviceAuthorizationGrant { data: DeviceAuthorizationResponse },
DeviceAuthorizationGrant {
data: DeviceAuthorizationResponse,
},
MFACode {
msg: String,
},
MFAPoll {
/// Message to display to the user.
msg: String,
/// Interval in seconds between poll attemts.
polling_interval: u32,
},
MFAPollWait,
}
#[allow(clippy::from_over_into)]
@ -78,6 +103,15 @@ impl Into<PamAuthResponse> for AuthRequest {
AuthRequest::DeviceAuthorizationGrant { data } => {
PamAuthResponse::DeviceAuthorizationGrant { data }
}
AuthRequest::MFACode { msg } => PamAuthResponse::MFACode { msg },
AuthRequest::MFAPoll {
msg,
polling_interval,
} => PamAuthResponse::MFAPoll {
msg,
polling_interval,
},
AuthRequest::MFAPollWait => PamAuthResponse::MFAPollWait,
}
}
}
@ -133,6 +167,7 @@ pub trait IdProvider {
_token: Option<&UserToken>,
_tpm: &mut tpm::BoxedDynTpm,
_machine_key: &tpm::MachineKey,
_shutdown_rx: &broadcast::Receiver<()>,
) -> Result<(AuthRequest, AuthCredHandler), IdpError>;
async fn unix_user_online_auth_step<D: KeyStoreTxn + Send>(
@ -143,6 +178,7 @@ pub trait IdProvider {
_keystore: &mut D,
_tpm: &mut tpm::BoxedDynTpm,
_machine_key: &tpm::MachineKey,
_shutdown_rx: &broadcast::Receiver<()>,
) -> Result<(AuthResult, AuthCacheAction), IdpError>;
async fn unix_user_offline_auth_init(

View file

@ -3,7 +3,7 @@ use async_trait::async_trait;
use kanidm_client::{ClientError, KanidmClient, StatusCode};
use kanidm_proto::internal::OperationError;
use kanidm_proto::v1::{UnixGroupToken, UnixUserToken};
use tokio::sync::RwLock;
use tokio::sync::{broadcast, RwLock};
use super::interface::{
// KeyStore,
@ -197,6 +197,7 @@ impl IdProvider for KanidmProvider {
_token: Option<&UserToken>,
_tpm: &mut tpm::BoxedDynTpm,
_machine_key: &tpm::MachineKey,
_shutdown_rx: &broadcast::Receiver<()>,
) -> Result<(AuthRequest, AuthCredHandler), IdpError> {
// Not sure that I need to do much here?
Ok((AuthRequest::Password, AuthCredHandler::Password))
@ -210,6 +211,7 @@ impl IdProvider for KanidmProvider {
_keystore: &mut D,
_tpm: &mut tpm::BoxedDynTpm,
_machine_key: &tpm::MachineKey,
_shutdown_rx: &broadcast::Receiver<()>,
) -> Result<(AuthResult, AuthCacheAction), IdpError> {
match (cred_handler, pam_next_req) {
(AuthCredHandler::Password, PamAuthRequest::Password { cred }) => {

View file

@ -28,6 +28,8 @@ use crate::unix_proto::{HomeDirectoryInfo, NssGroup, NssUser, PamAuthRequest, Pa
use kanidm_hsm_crypto::{BoxedDynTpm, HmacKey, MachineKey, Tpm};
use tokio::sync::broadcast;
const NXCACHE_SIZE: NonZeroUsize = unsafe { NonZeroUsize::new_unchecked(128) };
#[derive(Debug, Clone)]
@ -44,9 +46,12 @@ pub enum AuthSession {
id: Id,
token: Option<Box<UserToken>>,
online_at_init: bool,
// cred_type: AuthCredType,
// next_cred: AuthNextCred,
cred_handler: AuthCredHandler,
/// Some authentication operations may need to spawn background tasks. These tasks need
/// to know when to stop as the caller has disconnected. This reciever allows that, so
/// that tasks which .resubscribe() to this channel can then select! on it and be notified
/// when they need to stop.
shutdown_rx: broadcast::Receiver<()>,
},
Success,
Denied,
@ -867,6 +872,7 @@ where
pub async fn pam_account_authenticate_init(
&self,
account_id: &str,
shutdown_rx: broadcast::Receiver<()>,
) -> Result<(AuthSession, PamAuthResponse), ()> {
// Setup an auth session. If possible bring the resolver online.
// Further steps won't attempt to bring the cache online to prevent
@ -893,6 +899,7 @@ where
token.as_ref(),
hsm_lock.deref_mut(),
&self.machine_key,
&shutdown_rx,
)
.await
} else {
@ -910,6 +917,7 @@ where
token: token.map(Box::new),
online_at_init,
cred_handler,
shutdown_rx,
};
// Now identify what credentials are needed next. The auth session tells
@ -945,6 +953,7 @@ where
token: _,
online_at_init: true,
ref mut cred_handler,
ref shutdown_rx,
},
CacheState::Online,
) => {
@ -960,6 +969,7 @@ where
&mut dbtxn,
hsm_lock.deref_mut(),
&self.machine_key,
shutdown_rx,
)
.await;
@ -1008,6 +1018,8 @@ where
token: Some(ref token),
online_at_init: _,
ref mut cred_handler,
// Only need in online auth.
shutdown_rx: _,
},
_,
) => {
@ -1038,6 +1050,10 @@ where
// AuthCredHandler::DeviceAuthorizationGrant is invalid for offline auth
return Err(());
}
(AuthCredHandler::MFA { .. }, _) => {
// AuthCredHandler::MFA is invalid for offline auth
return Err(());
}
}
/*
@ -1102,7 +1118,12 @@ where
account_id: &str,
password: &str,
) -> Result<Option<bool>, ()> {
let mut auth_session = match self.pam_account_authenticate_init(account_id).await? {
let (_shutdown_tx, shutdown_rx) = broadcast::channel(1);
let mut auth_session = match self
.pam_account_authenticate_init(account_id, shutdown_rx)
.await?
{
(auth_session, PamAuthResponse::Password) => {
// Can continue!
auth_session
@ -1111,6 +1132,18 @@ where
// Can continue!
auth_session
}
(auth_session, PamAuthResponse::MFACode { .. }) => {
// Can continue!
auth_session
}
(auth_session, PamAuthResponse::MFAPoll { .. }) => {
// Can continue!
auth_session
}
(auth_session, PamAuthResponse::MFAPollWait) => {
// Can continue!
auth_session
}
(_, PamAuthResponse::Unknown) => return Ok(None),
(_, PamAuthResponse::Denied) => return Ok(Some(false)),
(_, PamAuthResponse::Success) => {

View file

@ -36,22 +36,30 @@ pub enum PamAuthResponse {
Success,
Denied,
Password,
DeviceAuthorizationGrant { data: DeviceAuthorizationResponse },
/*
MFACode {
DeviceAuthorizationGrant {
data: DeviceAuthorizationResponse,
},
*/
/// PAM must prompt for an authentication code
MFACode {
msg: String,
},
/// PAM will poll for an external response
MFAPoll {
/// Initial message to display as the polling begins.
msg: String,
/// Seconds between polling attempts.
polling_interval: u32,
},
MFAPollWait,
// CTAP2
}
#[derive(Serialize, Deserialize, Debug)]
pub enum PamAuthRequest {
Password { cred: String },
DeviceAuthorizationGrant { data: DeviceAuthorizationResponse }, /*
MFACode {
cred: Option<PamCred>
}
*/
DeviceAuthorizationGrant { data: DeviceAuthorizationResponse },
MFACode { cred: String },
MFAPoll,
}
#[derive(Serialize, Deserialize, Debug)]