kanidm unixd mfa capabilities (#2672)

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

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/_token", get(group_id_unix_token_get))
.route("/v1/group/:id/_unix", post(group_id_unix_post)) .route("/v1/group/:id/_unix", post(group_id_unix_post))
.route("/v1/group", get(group_get).post(group_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( .route(
"/v1/group/:id/_attr/:attr", "/v1/group/:id/_attr/:attr",
delete(group_id_attr_delete) delete(group_id_attr_delete)

View file

@ -53,6 +53,9 @@ use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::fmt; use tracing_subscriber::fmt;
use tracing_subscriber::prelude::*; use tracing_subscriber::prelude::*;
use std::thread;
use std::time::Duration;
pub fn get_cfg() -> Result<KanidmUnixdConfig, PamResultCode> { pub fn get_cfg() -> Result<KanidmUnixdConfig, PamResultCode> {
KanidmUnixdConfig::new() KanidmUnixdConfig::new()
.read_options_from_optional_config(DEFAULT_CONFIG_PATH) .read_options_from_optional_config(DEFAULT_CONFIG_PATH)
@ -106,6 +109,38 @@ pub struct PamKanidm;
pam_hooks!(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 { impl PamHooks for PamKanidm {
fn acct_mgmt(pamh: &PamHandle, args: Vec<&CStr>, _flags: PamFlag) -> PamResultCode { fn acct_mgmt(pamh: &PamHandle, args: Vec<&CStr>, _flags: PamFlag) -> PamResultCode {
let opts = match Options::try_from(&args) { let opts = match Options::try_from(&args) {
@ -241,85 +276,129 @@ impl PamHooks for PamKanidm {
let mut req = ClientRequest::PamAuthenticateInit(account_id); let mut req = ClientRequest::PamAuthenticateInit(account_id);
loop { loop {
match daemon_client.call_and_wait(&req, timeout) { match_sm_auth_client_response!(daemon_client.call_and_wait(&req, timeout), opts,
Ok(r) => match r { ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Password) => {
ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Success) => { let mut consume_authtok = None;
return PamResultCode::PAM_SUCCESS; // 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
ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Denied) => { // here effectively.
return PamResultCode::PAM_AUTH_ERR; std::mem::swap(&mut authtok, &mut consume_authtok);
} let cred = if let Some(cred) = consume_authtok {
ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Unknown) => { cred
if opts.ignore_unknown_user { } else {
return PamResultCode::PAM_IGNORE; match conv.send(PAM_PROMPT_ECHO_OFF, "Password: ") {
} else { Ok(password) => match password {
return PamResultCode::PAM_USER_UNKNOWN; Some(cred) => cred,
} None => {
} debug!("no password");
ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Password) => { return PamResultCode::PAM_CRED_INSUFFICIENT;
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;
} }
} },
};
// 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) => { Err(err) => {
if opts.debug { debug!("unable to get password");
println!("Message prompt failed");
}
return err; return err;
} }
} }
};
timeout = u64::from(data.expires_in); // Now setup the request for the next loop.
req = ClientRequest::PamAuthenticateStep( timeout = cfg.unix_sock_timeout;
PamAuthRequest::DeviceAuthorizationGrant { data }, req = ClientRequest::PamAuthenticateStep(PamAuthRequest::Password { cred });
); continue;
continue;
}
_ => {
// unexpected response.
error!(err = ?r, "PAM_IGNORE, unexpected resolver response");
return PamResultCode::PAM_IGNORE;
}
}, },
Err(err) => { ClientResponse::PamAuthenticateStepResponse(
error!(?err, "PAM_IGNORE"); PamAuthResponse::DeviceAuthorizationGrant { data },
return PamResultCode::PAM_IGNORE; ) => {
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. } // 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 reqs = Framed::new(sock, ClientCodec);
let mut pam_auth_session_state = None; 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 ..."); trace!("Waiting for requests ...");
while let Some(Ok(req)) = reqs.next().await { while let Some(Ok(req)) = reqs.next().await {
let resp = match req { let resp = match req {
@ -295,7 +299,10 @@ async fn handle_client(
} }
None => { None => {
match cachelayer match cachelayer
.pam_account_authenticate_init(account_id.as_str()) .pam_account_authenticate_init(
account_id.as_str(),
shutdown_tx.subscribe(),
)
.await .await
{ {
Ok((auth_session, pam_auth_response)) => { Ok((auth_session, pam_auth_response)) => {
@ -407,6 +414,14 @@ async fn handle_client(
debug!("flushed response!"); 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 // Disconnect them
debug!("Disconnecting client ..."); debug!("Disconnecting client ...");
Ok(()) Ok(())

View file

@ -2,6 +2,7 @@ use crate::db::KeyStoreTxn;
use crate::unix_proto::{DeviceAuthorizationResponse, PamAuthRequest, PamAuthResponse}; use crate::unix_proto::{DeviceAuthorizationResponse, PamAuthRequest, PamAuthResponse};
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use uuid::Uuid; use uuid::Uuid;
pub use kanidm_hsm_crypto as tpm; pub use kanidm_hsm_crypto as tpm;
@ -63,11 +64,35 @@ pub struct UserToken {
pub enum AuthCredHandler { pub enum AuthCredHandler {
Password, Password,
DeviceAuthorizationGrant, 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 { pub enum AuthRequest {
Password, 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)] #[allow(clippy::from_over_into)]
@ -78,6 +103,15 @@ impl Into<PamAuthResponse> for AuthRequest {
AuthRequest::DeviceAuthorizationGrant { data } => { AuthRequest::DeviceAuthorizationGrant { data } => {
PamAuthResponse::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>, _token: Option<&UserToken>,
_tpm: &mut tpm::BoxedDynTpm, _tpm: &mut tpm::BoxedDynTpm,
_machine_key: &tpm::MachineKey, _machine_key: &tpm::MachineKey,
_shutdown_rx: &broadcast::Receiver<()>,
) -> Result<(AuthRequest, AuthCredHandler), IdpError>; ) -> Result<(AuthRequest, AuthCredHandler), IdpError>;
async fn unix_user_online_auth_step<D: KeyStoreTxn + Send>( async fn unix_user_online_auth_step<D: KeyStoreTxn + Send>(
@ -143,6 +178,7 @@ pub trait IdProvider {
_keystore: &mut D, _keystore: &mut D,
_tpm: &mut tpm::BoxedDynTpm, _tpm: &mut tpm::BoxedDynTpm,
_machine_key: &tpm::MachineKey, _machine_key: &tpm::MachineKey,
_shutdown_rx: &broadcast::Receiver<()>,
) -> Result<(AuthResult, AuthCacheAction), IdpError>; ) -> Result<(AuthResult, AuthCacheAction), IdpError>;
async fn unix_user_offline_auth_init( 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_client::{ClientError, KanidmClient, StatusCode};
use kanidm_proto::internal::OperationError; use kanidm_proto::internal::OperationError;
use kanidm_proto::v1::{UnixGroupToken, UnixUserToken}; use kanidm_proto::v1::{UnixGroupToken, UnixUserToken};
use tokio::sync::RwLock; use tokio::sync::{broadcast, RwLock};
use super::interface::{ use super::interface::{
// KeyStore, // KeyStore,
@ -197,6 +197,7 @@ impl IdProvider for KanidmProvider {
_token: Option<&UserToken>, _token: Option<&UserToken>,
_tpm: &mut tpm::BoxedDynTpm, _tpm: &mut tpm::BoxedDynTpm,
_machine_key: &tpm::MachineKey, _machine_key: &tpm::MachineKey,
_shutdown_rx: &broadcast::Receiver<()>,
) -> Result<(AuthRequest, AuthCredHandler), IdpError> { ) -> Result<(AuthRequest, AuthCredHandler), IdpError> {
// Not sure that I need to do much here? // Not sure that I need to do much here?
Ok((AuthRequest::Password, AuthCredHandler::Password)) Ok((AuthRequest::Password, AuthCredHandler::Password))
@ -210,6 +211,7 @@ impl IdProvider for KanidmProvider {
_keystore: &mut D, _keystore: &mut D,
_tpm: &mut tpm::BoxedDynTpm, _tpm: &mut tpm::BoxedDynTpm,
_machine_key: &tpm::MachineKey, _machine_key: &tpm::MachineKey,
_shutdown_rx: &broadcast::Receiver<()>,
) -> Result<(AuthResult, AuthCacheAction), IdpError> { ) -> Result<(AuthResult, AuthCacheAction), IdpError> {
match (cred_handler, pam_next_req) { match (cred_handler, pam_next_req) {
(AuthCredHandler::Password, PamAuthRequest::Password { cred }) => { (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 kanidm_hsm_crypto::{BoxedDynTpm, HmacKey, MachineKey, Tpm};
use tokio::sync::broadcast;
const NXCACHE_SIZE: NonZeroUsize = unsafe { NonZeroUsize::new_unchecked(128) }; const NXCACHE_SIZE: NonZeroUsize = unsafe { NonZeroUsize::new_unchecked(128) };
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -44,9 +46,12 @@ pub enum AuthSession {
id: Id, id: Id,
token: Option<Box<UserToken>>, token: Option<Box<UserToken>>,
online_at_init: bool, online_at_init: bool,
// cred_type: AuthCredType,
// next_cred: AuthNextCred,
cred_handler: AuthCredHandler, 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, Success,
Denied, Denied,
@ -867,6 +872,7 @@ where
pub async fn pam_account_authenticate_init( pub async fn pam_account_authenticate_init(
&self, &self,
account_id: &str, account_id: &str,
shutdown_rx: broadcast::Receiver<()>,
) -> Result<(AuthSession, PamAuthResponse), ()> { ) -> Result<(AuthSession, PamAuthResponse), ()> {
// Setup an auth session. If possible bring the resolver online. // Setup an auth session. If possible bring the resolver online.
// Further steps won't attempt to bring the cache online to prevent // Further steps won't attempt to bring the cache online to prevent
@ -893,6 +899,7 @@ where
token.as_ref(), token.as_ref(),
hsm_lock.deref_mut(), hsm_lock.deref_mut(),
&self.machine_key, &self.machine_key,
&shutdown_rx,
) )
.await .await
} else { } else {
@ -910,6 +917,7 @@ where
token: token.map(Box::new), token: token.map(Box::new),
online_at_init, online_at_init,
cred_handler, cred_handler,
shutdown_rx,
}; };
// Now identify what credentials are needed next. The auth session tells // Now identify what credentials are needed next. The auth session tells
@ -945,6 +953,7 @@ where
token: _, token: _,
online_at_init: true, online_at_init: true,
ref mut cred_handler, ref mut cred_handler,
ref shutdown_rx,
}, },
CacheState::Online, CacheState::Online,
) => { ) => {
@ -960,6 +969,7 @@ where
&mut dbtxn, &mut dbtxn,
hsm_lock.deref_mut(), hsm_lock.deref_mut(),
&self.machine_key, &self.machine_key,
shutdown_rx,
) )
.await; .await;
@ -1008,6 +1018,8 @@ where
token: Some(ref token), token: Some(ref token),
online_at_init: _, online_at_init: _,
ref mut cred_handler, ref mut cred_handler,
// Only need in online auth.
shutdown_rx: _,
}, },
_, _,
) => { ) => {
@ -1038,6 +1050,10 @@ where
// AuthCredHandler::DeviceAuthorizationGrant is invalid for offline auth // AuthCredHandler::DeviceAuthorizationGrant is invalid for offline auth
return Err(()); return Err(());
} }
(AuthCredHandler::MFA { .. }, _) => {
// AuthCredHandler::MFA is invalid for offline auth
return Err(());
}
} }
/* /*
@ -1102,7 +1118,12 @@ where
account_id: &str, account_id: &str,
password: &str, password: &str,
) -> Result<Option<bool>, ()> { ) -> 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) => { (auth_session, PamAuthResponse::Password) => {
// Can continue! // Can continue!
auth_session auth_session
@ -1111,6 +1132,18 @@ where
// Can continue! // Can continue!
auth_session 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::Unknown) => return Ok(None),
(_, PamAuthResponse::Denied) => return Ok(Some(false)), (_, PamAuthResponse::Denied) => return Ok(Some(false)),
(_, PamAuthResponse::Success) => { (_, PamAuthResponse::Success) => {

View file

@ -36,22 +36,30 @@ pub enum PamAuthResponse {
Success, Success,
Denied, Denied,
Password, Password,
DeviceAuthorizationGrant { data: DeviceAuthorizationResponse }, DeviceAuthorizationGrant {
/* data: DeviceAuthorizationResponse,
MFACode {
}, },
*/ /// 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 // CTAP2
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub enum PamAuthRequest { pub enum PamAuthRequest {
Password { cred: String }, Password { cred: String },
DeviceAuthorizationGrant { data: DeviceAuthorizationResponse }, /* DeviceAuthorizationGrant { data: DeviceAuthorizationResponse },
MFACode { MFACode { cred: String },
cred: Option<PamCred> MFAPoll,
}
*/
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]