diff --git a/platform/debian/himmelblau.conf.example b/platform/debian/himmelblau.conf.example index 290aae63..5ef55205 100644 --- a/platform/debian/himmelblau.conf.example +++ b/platform/debian/himmelblau.conf.example @@ -49,6 +49,23 @@ # configuration. # cn_name_mapping = true # +# A comma seperated list of local groups that every Entra Id user should be a +# member of. For example, you may wish for all Entra Id users to be a member +# of the sudo group. WARNING: This setting will not REMOVE group member entries +# when groups are removed from this list. You must remove them manually. +local_groups = users +# +# Logon user script. This script will execute every time a user logs on. Two +# environment variables are set: USERNAME, and ACCESS_TOKEN. The ACCESS_TOKEN +# environment variable is an access token for the MS graph. The token scope +# config option sets the comma separated scopes that should be requested for +# the ACCESS_TOKEN. ACCESS_TOKEN will be empty during offline logon. The return +# code of the script determines how the authentication proceeds. 0 is success, +# 1 is a soft failure and authentication will proceed, while 2 is a hard +# failure causing authentication to fail. +# logon_script = +# logon_token_scopes = +# # authority_host = login.microsoftonline.com # # The location of the cache database diff --git a/src/common/src/config.rs b/src/common/src/config.rs index ee63dd91..0eb96744 100644 --- a/src/common/src/config.rs +++ b/src/common/src/config.rs @@ -47,6 +47,7 @@ pub fn split_username(username: &str) -> Option<(&str, &str)> { None } +#[derive(Clone)] pub struct HimmelblauConfig { config: Ini, filename: String, @@ -453,6 +454,24 @@ impl HimmelblauConfig { pub fn get_tenant_id(&self, domain: &str) -> Option { self.config.get(domain, "tenant_id") } + + pub fn get_local_groups(&self) -> Vec { + match self.config.get("global", "local_groups") { + Some(val) => val.split(',').map(|s| s.to_string()).collect(), + None => vec![], + } + } + + pub fn get_logon_script(&self) -> Option { + self.config.get("global", "logon_script") + } + + pub fn get_logon_token_scopes(&self) -> Vec { + match self.config.get("global", "logon_token_scopes") { + Some(scopes) => scopes.split(",").map(|s| s.to_string()).collect(), + None => vec![], + } + } } impl fmt::Debug for HimmelblauConfig { diff --git a/src/common/src/resolver.rs b/src/common/src/resolver.rs index f1f41ddc..b4bb669f 100644 --- a/src/common/src/resolver.rs +++ b/src/common/src/resolver.rs @@ -66,7 +66,7 @@ pub enum AuthSession { /// when they need to stop. shutdown_rx: broadcast::Receiver<()>, }, - Success, + Success(String), Denied, } @@ -1167,7 +1167,7 @@ where warn!("Unable to proceed with offline auth, no token available"); Err(IdpError::NotFound) } - (&mut AuthSession::Success, _) | (&mut AuthSession::Denied, _) => { + (&mut AuthSession::Success(_), _) | (&mut AuthSession::Denied, _) => { Err(IdpError::BadRequest) } }; @@ -1184,7 +1184,7 @@ where } else { debug!("provider authentication success."); self.set_cache_usertoken(&mut token).await?; - *auth_session = AuthSession::Success; + *auth_session = AuthSession::Success(token.spn); Ok(PamAuthResponse::Success) } diff --git a/src/common/src/unix_proto.rs b/src/common/src/unix_proto.rs index 385b3421..141acf74 100644 --- a/src/common/src/unix_proto.rs +++ b/src/common/src/unix_proto.rs @@ -131,11 +131,13 @@ pub struct HomeDirectoryInfo { #[derive(Serialize, Deserialize, Debug, Clone)] pub enum TaskRequest { HomeDirectory(HomeDirectoryInfo), + LocalGroups(String), + LogonScript(String, String), } #[derive(Serialize, Deserialize, Debug)] pub enum TaskResponse { - Success, + Success(i32), Error(String), } diff --git a/src/config/himmelblau.conf.example b/src/config/himmelblau.conf.example index b2b5a709..81d6651d 100644 --- a/src/config/himmelblau.conf.example +++ b/src/config/himmelblau.conf.example @@ -49,6 +49,23 @@ # configuration. # cn_name_mapping = true # +# A comma seperated list of local groups that every Entra Id user should be a +# member of. For example, you may wish for all Entra Id users to be a member +# of the sudo group. WARNING: This setting will not REMOVE group member entries +# when groups are removed from this list. You must remove them manually. +# local_groups = +# +# Logon user script. This script will execute every time a user logs on. Two +# environment variables are set: USERNAME, and ACCESS_TOKEN. The ACCESS_TOKEN +# environment variable is an access token for the MS graph. The token scope +# config option sets the comma separated scopes that should be requested for +# the ACCESS_TOKEN. ACCESS_TOKEN will be empty during offline logon. The return +# code of the script determines how the authentication proceeds. 0 is success, +# 1 is a soft failure and authentication will proceed, while 2 is a hard +# failure causing authentication to fail. +# logon_script = +# logon_token_scopes = +# # authority_host = login.microsoftonline.com # # The location of the cache database diff --git a/src/daemon/src/daemon.rs b/src/daemon/src/daemon.rs index 1e957434..862ee3f7 100644 --- a/src/daemon/src/daemon.rs +++ b/src/daemon/src/daemon.rs @@ -37,11 +37,12 @@ use himmelblau_unix_common::config::HimmelblauConfig; use himmelblau_unix_common::constants::DEFAULT_CONFIG_PATH; use himmelblau_unix_common::db::{Cache, CacheTxn, Db}; use himmelblau_unix_common::idprovider::himmelblau::HimmelblauMultiProvider; -use himmelblau_unix_common::resolver::Resolver; +use himmelblau_unix_common::idprovider::interface::Id; +use himmelblau_unix_common::resolver::{AuthSession, Resolver}; use himmelblau_unix_common::unix_config::{HsmType, UidAttr}; use himmelblau_unix_common::unix_passwd::{parse_etc_group, parse_etc_passwd}; use himmelblau_unix_common::unix_proto::{ - ClientRequest, ClientResponse, TaskRequest, TaskResponse, + ClientRequest, ClientResponse, PamAuthResponse, TaskRequest, TaskResponse, }; use kanidm_utils_users::{get_current_gid, get_current_uid, get_effective_gid, get_effective_uid}; @@ -68,7 +69,7 @@ use identity_dbus_broker::himmelblau_broker_serve; //=== the codec -type AsyncTaskRequest = (TaskRequest, oneshot::Sender<()>); +type AsyncTaskRequest = (TaskRequest, oneshot::Sender); #[derive(Default)] struct ClientCodec; @@ -184,11 +185,11 @@ async fn handle_task_client( } match reqs.next().await { - Some(Ok(TaskResponse::Success)) => { + Some(Ok(TaskResponse::Success(status))) => { debug!("Task was acknowledged and completed."); // Send a result back via the one-shot // Ignore if it fails. - let _ = v.1.send(()); + let _ = v.1.send(status); } other => { error!("Error -> {:?}", other); @@ -202,6 +203,7 @@ async fn handle_client( sock: UnixStream, cachelayer: Arc>, task_channel_tx: &Sender, + cfg: HimmelblauConfig, ) -> Result<(), Box> { debug!("Accepted connection"); @@ -319,11 +321,95 @@ async fn handle_client( ClientRequest::PamAuthenticateStep(pam_next_req) => { debug!("pam authenticate step"); match &mut pam_auth_session_state { - Some(auth_session) => cachelayer - .pam_account_authenticate_step(auth_session, pam_next_req) - .await - .map(|pam_auth_response| pam_auth_response.into()) - .unwrap_or(ClientResponse::Error), + Some(auth_session) => { + match cachelayer + .pam_account_authenticate_step(auth_session, pam_next_req) + .await + .map(|pam_auth_response| pam_auth_response.into()) + .unwrap_or(ClientResponse::Error) + { + ClientResponse::PamAuthenticateStepResponse(resp) => { + macro_rules! ret { + () => { + ClientResponse::PamAuthenticateStepResponse(resp) + }; + } + match auth_session { + AuthSession::Success(account_id) => { + match resp { + PamAuthResponse::Success => { + if cfg.get_logon_script().is_some() { + let scopes = cfg.get_logon_token_scopes(); + let access_token = match cachelayer + .get_user_accesstoken( + Id::Name(account_id.to_string()), + scopes, + ) + .await + { + Some(token) => token + .access_token + .clone() + .unwrap_or("".to_string()), + None => "".to_string(), + }; + + let (tx, rx) = oneshot::channel(); + + match task_channel_tx + .send_timeout( + ( + TaskRequest::LogonScript( + account_id.to_string(), + access_token.to_string(), + ), + tx, + ), + Duration::from_millis(100), + ) + .await + { + Ok(()) => { + // Now wait for the other end OR timeout. + match time::timeout_at( + time::Instant::now() + + Duration::from_secs(60), + rx, + ) + .await + { + Ok(Ok(status)) => { + if status == 2 { + debug!("Authentication was explicitly denied by the logon script"); + ClientResponse::PamAuthenticateStepResponse(PamAuthResponse::Denied) + } else { + ret!() + } + } + _ => { + error!("Execution of logon script failed"); + ret!() + } + } + } + Err(e) => { + error!("Execution of logon script failed: {:?}", e); + ret!() + } + } + } else { + ret!() + } + } + _ => ret!(), + } + } + _ => ret!(), + } + } + other => other, + } + } None => { warn!("Attempt to continue auth session while current session is inactive"); ClientResponse::Error @@ -347,7 +433,7 @@ async fn handle_client( Ok(Some(info)) => { let (tx, rx) = oneshot::channel(); - match task_channel_tx + let resp1 = match task_channel_tx .send_timeout( (TaskRequest::HomeDirectory(info), tx), Duration::from_millis(100), @@ -376,6 +462,44 @@ async fn handle_client( // We could not submit the req. Move on! ClientResponse::Error } + }; + + let (tx, rx) = oneshot::channel(); + + let resp2 = match task_channel_tx + .send_timeout( + (TaskRequest::LocalGroups(account_id.to_string()), tx), + Duration::from_millis(100), + ) + .await + { + Ok(()) => { + // Now wait for the other end OR timeout. + match time::timeout_at( + time::Instant::now() + Duration::from_millis(1000), + rx, + ) + .await + { + Ok(Ok(_)) => { + debug!("Task completed, returning to pam ..."); + ClientResponse::Ok + } + _ => { + // Timeout or other error. + ClientResponse::Error + } + } + } + Err(_) => { + // We could not submit the req. Move on! + ClientResponse::Error + } + }; + + match resp1 { + ClientResponse::Error => ClientResponse::Error, + _ => resp2, } } _ => ClientResponse::Error, @@ -815,6 +939,12 @@ async fn main() -> ExitCode { return ExitCode::FAILURE } + // Setup the tasks socket first. + let (task_channel_tx, mut task_channel_rx) = channel(16); + let task_channel_tx = Arc::new(task_channel_tx); + + let task_channel_tx_cln = task_channel_tx.clone(); + let cl_inner = match Resolver::new( db, idprovider, @@ -859,12 +989,6 @@ async fn main() -> ExitCode { return ExitCode::FAILURE } - // Setup the tasks socket first. - let (task_channel_tx, mut task_channel_rx) = channel(16); - let task_channel_tx = Arc::new(task_channel_tx); - - let task_channel_tx_cln = task_channel_tx.clone(); - // Start to build the worker tasks let (broadcast_tx, mut broadcast_rx) = broadcast::channel(4); let mut c_broadcast_rx = broadcast_tx.subscribe(); @@ -993,6 +1117,7 @@ async fn main() -> ExitCode { let task_a = tokio::spawn(async move { loop { let tc_tx = task_channel_tx_cln.clone(); + let cfg_h = cfg.clone(); tokio::select! { _ = broadcast_rx.recv() => { @@ -1003,7 +1128,7 @@ async fn main() -> ExitCode { Ok((socket, _addr)) => { let cachelayer_ref = cachelayer.clone(); tokio::spawn(async move { - if let Err(e) = handle_client(socket, cachelayer_ref.clone(), &tc_tx).await + if let Err(e) = handle_client(socket, cachelayer_ref.clone(), &tc_tx, cfg_h).await { error!("handle_client error occurred; error = {:?}", e); } diff --git a/src/daemon/src/tasks_daemon.rs b/src/daemon/src/tasks_daemon.rs index 51e589e5..03e3c60c 100644 --- a/src/daemon/src/tasks_daemon.rs +++ b/src/daemon/src/tasks_daemon.rs @@ -25,6 +25,7 @@ use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::symlink; use std::path::Path; use std::process::ExitCode; +use std::str; use std::time::Duration; use std::{fs, io}; @@ -38,6 +39,7 @@ use libc::{lchown, umask}; use sketching::tracing_forest::traits::*; use sketching::tracing_forest::util::*; use sketching::tracing_forest::{self}; +use std::process::Command; use tokio::net::UnixStream; use tokio::sync::broadcast; use tokio::time; @@ -236,6 +238,64 @@ fn create_home_directory( Ok(()) } +fn add_user_to_group(account_id: &str, local_group: &str) { + match Command::new("usermod") + .arg("-aG") + .arg(local_group) + .arg(account_id) + .output() + { + Ok(res) => { + if !res.status.success() { + error!( + "Failed adding user {} to local group {}", + account_id, local_group + ); + } + } + Err(e) => { + error!( + "Failed adding user {} to local group {}: {:?}", + account_id, local_group, e + ); + } + } +} + +fn execute_user_script(account_id: &str, script: &str, access_token: &str) -> i32 { + match Command::new("sh") + .arg("-c") + .arg(script) + .env("USERNAME", account_id) + .env("ACCESS_TOKEN", access_token) + .output() + { + Ok(res) => { + if !res.status.success() { + let stdout = str::from_utf8(&res.stdout).unwrap_or("Invalid UTF-8 in stdout"); + let stderr = str::from_utf8(&res.stderr).unwrap_or("Invalid UTF-8 in stderr"); + error!( + "Failed to execute script '{}':\nstdout: {}\nstderr: {}", + script, stdout, stderr + ); + } + + // If we don't get a status code, make assumptions + if res.status.success() { + res.status.code().unwrap_or(0) + } else { + res.status.code().unwrap_or(2) + } + } + Err(e) => { + error!("Failed to execute script '{}': {:?}", script, e); + // If the script fails to execute at all, we assume this is a hard + // failure and terminate the authentication attempt. + 2 + } + } +} + async fn handle_tasks(stream: UnixStream, cfg: &HimmelblauConfig) { let mut reqs = Framed::new(stream, TaskCodec::new()); @@ -251,7 +311,7 @@ async fn handle_tasks(stream: UnixStream, cfg: &HimmelblauConfig) { cfg.get_use_etc_skel(), cfg.get_selinux(), ) { - Ok(()) => TaskResponse::Success, + Ok(()) => TaskResponse::Success(0), Err(msg) => TaskResponse::Error(msg), }; @@ -262,6 +322,30 @@ async fn handle_tasks(stream: UnixStream, cfg: &HimmelblauConfig) { } // All good, loop. } + Some(Ok(TaskRequest::LocalGroups(account_id))) => { + let local_groups = cfg.get_local_groups(); + for local_group in local_groups { + add_user_to_group(&account_id, &local_group); + } + + // Always indicate success here + if let Err(e) = reqs.send(TaskResponse::Success(0)).await { + error!("Error -> {:?}", e); + return; + } + } + Some(Ok(TaskRequest::LogonScript(account_id, access_token))) => { + let mut status = 0; + if let Some(script) = cfg.get_logon_script() { + status = execute_user_script(&account_id, &script, &access_token); + } + + // Indicate the status response + if let Err(e) = reqs.send(TaskResponse::Success(status)).await { + error!("Error -> {:?}", e); + return; + } + } other => { error!("Error -> {:?}", other); return;