diff --git a/crates/ironrdp-server/src/builder.rs b/crates/ironrdp-server/src/builder.rs index 0795e6001..3779f3646 100644 --- a/crates/ironrdp-server/src/builder.rs +++ b/crates/ironrdp-server/src/builder.rs @@ -11,7 +11,9 @@ use super::display::{DesktopSize, RdpServerDisplay}; #[cfg(feature = "egfx")] use super::gfx::GfxServerFactory; use super::handler::{KeyboardEvent, MouseEvent, RdpServerInputHandler}; -use super::server::{ConnectionHandler, CredentialValidator, RdpServer, RdpServerOptions, RdpServerSecurity}; +use super::server::{ + ConnectionBinder, ConnectionHandler, CredentialValidator, RdpServer, RdpServerOptions, RdpServerSecurity, +}; use crate::{DisplayUpdate, RdpServerDisplayUpdates, SoundServerFactory}; pub struct WantsAddr {} @@ -38,6 +40,7 @@ pub struct BuilderDone { sound_factory: Option>, connection_handler: Option>, credential_validator: Option>, + connection_binder: Option>, #[cfg(feature = "egfx")] gfx_factory: Option>, display_suppressed: Option>, @@ -137,6 +140,7 @@ impl RdpServerBuilder { cliprdr_factory: None, connection_handler: None, credential_validator: None, + connection_binder: None, codecs: server_codecs_capabilities(&[]).expect("can't panic for &[]"), max_request_size: RdpServerOptions::DEFAULT_MAX_REQUEST_SIZE, #[cfg(feature = "egfx")] @@ -159,6 +163,7 @@ impl RdpServerBuilder { cliprdr_factory: None, connection_handler: None, credential_validator: None, + connection_binder: None, codecs: server_codecs_capabilities(&[]).expect("can't panic for &[]"), max_request_size: RdpServerOptions::DEFAULT_MAX_REQUEST_SIZE, #[cfg(feature = "egfx")] @@ -278,6 +283,12 @@ impl RdpServerBuilder { self } + /// Set a binder that replaces display/input handlers after credentials are accepted. + pub fn with_connection_binder(mut self, binder: Option>) -> Self { + self.state.connection_binder = binder; + self + } + /// Inject a shared NetworkAutoDetect RTT handle (milliseconds, `u32::MAX` /// until the first measurement). The server writes the latest measured RTT /// to the same instance the backend reads. When not called, the server @@ -309,6 +320,7 @@ impl RdpServerBuilder { self.state.autodetect_rtt, ); server.set_credential_validator(self.state.credential_validator); + server.set_connection_binder(self.state.connection_binder); server } } diff --git a/crates/ironrdp-server/src/lib.rs b/crates/ironrdp-server/src/lib.rs index 505d07a2f..fcebab562 100644 --- a/crates/ironrdp-server/src/lib.rs +++ b/crates/ironrdp-server/src/lib.rs @@ -33,9 +33,9 @@ pub use handler::{KeyboardEvent, MouseEvent, RdpServerInputHandler}; #[cfg(feature = "helper")] pub use helper::TlsIdentityCtx; pub use server::{ - ConnectionHandler, CredentialDecision, CredentialValidationError, CredentialValidator, Credentials, - ExactMatchCredentialValidator, PostConnectionAction, RdpServer, RdpServerOptions, RdpServerSecurity, ServerEvent, - ServerEventSender, TransportTls, + BoundConnection, ConnectionBinder, ConnectionHandler, CredentialDecision, CredentialValidationError, + CredentialValidator, Credentials, ExactMatchCredentialValidator, PostConnectionAction, RdpServer, RdpServerOptions, + RdpServerSecurity, ServerEvent, ServerEventSender, TransportTls, }; pub use sound::{RdpsndServerHandler, RdpsndServerMessage, SoundServerFactory}; diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index acdfe99dc..fc9bf8a79 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -190,6 +190,28 @@ pub trait CredentialValidator: Send + Sync { async fn validate(&self, credentials: &Credentials) -> Result; } +/// Display/input objects bound after a credential validator accepts a client. +/// +/// Servers with per-user desktop/session isolation can start with placeholder +/// display and input handlers, validate the client's credentials, and then +/// replace those placeholders with handlers attached to the authenticated +/// user's session before the RDP client loop starts. +pub struct BoundConnection { + pub display: Box, + pub input: Box, +} + +/// Async post-auth connection binder. +/// +/// This hook runs after [`CredentialValidator`] accepts credentials and before +/// static channels, display updates, or input dispatch begin. It lets a server +/// bind display/input resources to the authenticated identity without creating +/// per-user resources before authentication. +#[async_trait::async_trait] +pub trait ConnectionBinder: Send + Sync { + async fn bind_connection(&self, credentials: &Credentials) -> Result; +} + /// A built-in [`CredentialValidator`] that accepts exactly one fixed set of credentials. /// /// This is the validation-policy equivalent of the acceptor's pre-loaded @@ -448,6 +470,7 @@ pub struct RdpServer { ev_receiver: Arc>>, creds: Option, credential_validator: Option>, + connection_binder: Option>, local_addr: Option, autodetect: Option, connection_handler: Option>, @@ -546,6 +569,7 @@ impl RdpServer { ev_receiver: Arc::new(Mutex::new(ev_receiver)), creds: None, credential_validator: None, + connection_binder: None, local_addr: None, autodetect: None, connection_handler, @@ -582,6 +606,15 @@ impl RdpServer { self.credential_validator = validator; } + /// Set or clear a post-auth connection binder. + /// + /// When set, the binder is called after credentials have been validated. + /// The returned display/input handlers replace the server defaults for the + /// accepted connection. + pub fn set_connection_binder(&mut self, binder: Option>) { + self.connection_binder = binder; + } + pub fn event_sender(&self) -> &mpsc::UnboundedSender { &self.ev_sender } @@ -1328,6 +1361,7 @@ impl RdpServer { reader: &mut Framed, writer: &mut Framed, result: AcceptorResult, + authenticated_credentials_cache: &mut Option, ) -> Result where R: FramedRead, @@ -1339,26 +1373,28 @@ impl RdpServer { // async server layer, rather than in the sans-I/O acceptor, because real validators // (PAM/LDAP/DB) are I/O-bound. On rejection, deny with a ServerSetErrorInfoPdu before // closing, matching the acceptor's exact-match denial path. - if let Some(validator) = self.credential_validator.clone() { - if let Some(creds) = &result.credentials { - match validator.validate(creds).await { - Ok(CredentialDecision::Accept) => { - debug!("Credential validation accepted"); - } - Ok(CredentialDecision::Reject) => { - warn!("Credential validation rejected"); - send_access_denied(result.io_channel_id, result.user_channel_id, writer).await?; - bail!("credential validation rejected"); - } - Err(e) => { - error!(error = %e, "Credential validator backend error"); - send_access_denied(result.io_channel_id, result.user_channel_id, writer).await?; - bail!("credential validation backend error"); - } - } - } else { - debug!("Skipping credential validation (no credentials in AcceptorResult)"); - } + let authenticated_credentials = resolve_authenticated_credentials( + self.credential_validator.clone(), + result.credentials.as_ref(), + result.reactivation, + authenticated_credentials_cache, + ) + .await?; + + if let Some(binder) = self.connection_binder.clone() { + let Some(credentials) = authenticated_credentials.as_ref() else { + warn!("Connection binder configured but no authenticated credentials are available"); + send_access_denied(result.io_channel_id, result.user_channel_id, writer).await?; + bail!("no authenticated credentials for connection binding"); + }; + + let bound = binder + .bind_connection(credentials) + .await + .context("connection binder failed")?; + *self.display.lock().await = bound.display; + *self.handler.lock().await = bound.input; + debug!("Connection binder installed display/input handlers"); } if !result.input_events.is_empty() { @@ -1690,6 +1726,8 @@ impl RdpServer { where S: AsyncRead + AsyncWrite + Sync + Send + Unpin, { + let mut authenticated_credentials_cache = None; + loop { let (new_framed, result) = ironrdp_acceptor::accept_finalize(framed, &mut acceptor) .await @@ -1697,7 +1735,10 @@ impl RdpServer { let (mut reader, mut writer) = split_tokio_framed(new_framed); - match self.client_accepted(&mut reader, &mut writer, result).await? { + match self + .client_accepted(&mut reader, &mut writer, result, &mut authenticated_credentials_cache) + .await? + { RunState::Continue => { unreachable!(); } @@ -1728,6 +1769,51 @@ impl RdpServer { } } +async fn resolve_authenticated_credentials( + credential_validator: Option>, + result_credentials: Option<&Credentials>, + reactivation: bool, + authenticated_credentials_cache: &mut Option, +) -> Result> { + if let Some(validator) = credential_validator { + if let Some(creds) = result_credentials { + match validator.validate(creds).await { + Ok(CredentialDecision::Accept) => { + debug!("Credential validation accepted"); + *authenticated_credentials_cache = Some(creds.clone()); + Ok(Some(creds.clone())) + } + Ok(CredentialDecision::Reject) => { + warn!("Credential validation rejected"); + bail!("credential validation rejected"); + } + Err(e) => { + error!(error = %e, "Credential validator backend error"); + bail!("credential validation backend error"); + } + } + } else if reactivation { + let credentials = authenticated_credentials_cache.clone(); + if credentials.is_some() { + debug!("Reusing cached authenticated credentials for reactivation"); + } else { + debug!("Skipping credential validation for reactivation without cached credentials"); + } + Ok(credentials) + } else { + debug!("Skipping credential validation (no credentials in AcceptorResult)"); + Ok(None) + } + } else if let Some(creds) = result_credentials { + *authenticated_credentials_cache = Some(creds.clone()); + Ok(Some(creds.clone())) + } else if reactivation { + Ok(authenticated_credentials_cache.clone()) + } else { + Ok(None) + } +} + /// Encode a server-initiated Share Data PDU for the IO channel. /// /// `share_id` is hard-coded to 0, matching the existing convention in @@ -1842,3 +1928,84 @@ impl<'a, W: FramedWrite> SharedWriter<'a, W> { } } } + +#[cfg(test)] +mod wrdp_reactivation_tests { + use super::*; + + struct AllowUserValidator(&'static str); + + #[async_trait::async_trait] + impl CredentialValidator for AllowUserValidator { + async fn validate(&self, credentials: &Credentials) -> Result { + if credentials.username == self.0 { + Ok(CredentialDecision::Accept) + } else { + Ok(CredentialDecision::Reject) + } + } + } + + fn creds(username: &str) -> Credentials { + Credentials { + username: username.to_owned(), + password: "secret".to_owned(), + domain: None, + } + } + + #[tokio::test] + async fn reactivation_without_credentials_reuses_same_connection_validated_identity() { + let validator = Arc::new(AllowUserValidator("alice")); + let mut per_connection_cache = None; + + let first = resolve_authenticated_credentials( + Some(validator.clone()), + Some(&creds("alice")), + false, + &mut per_connection_cache, + ) + .await + .expect("initial validation should succeed") + .expect("initial validation should produce credentials"); + assert_eq!(first.username, "alice"); + + let reactivated = resolve_authenticated_credentials( + Some(validator), + None, + true, + &mut per_connection_cache, + ) + .await + .expect("reactivation should reuse same-connection cache") + .expect("reactivation should have cached credentials"); + assert_eq!(reactivated.username, "alice"); + } + + #[tokio::test] + async fn reactivation_without_credentials_cannot_use_previous_tcp_connection_cache() { + let validator = Arc::new(AllowUserValidator("alice")); + let mut first_connection_cache = None; + resolve_authenticated_credentials( + Some(validator.clone()), + Some(&creds("alice")), + false, + &mut first_connection_cache, + ) + .await + .expect("initial validation should succeed"); + assert!(first_connection_cache.is_some()); + + let mut second_connection_cache = None; + let reactivated = resolve_authenticated_credentials( + Some(validator), + None, + true, + &mut second_connection_cache, + ) + .await + .expect("missing same-connection cache is not a backend error"); + assert!(reactivated.is_none()); + assert!(second_connection_cache.is_none()); + } +} diff --git a/docs/wrdp/auth-delegation.md b/docs/wrdp/auth-delegation.md new file mode 100644 index 000000000..1f7f1cfb6 --- /dev/null +++ b/docs/wrdp/auth-delegation.md @@ -0,0 +1,14 @@ +# Post-auth connection binding for multi-user servers + +`wrdp` follows the same multi-user architecture model as `xrdp-sesman`: a +single public RDP listener authenticates the client first, then delegates the +connection to a per-user desktop/session stack. + +That model needs a server hook that runs after credentials have been accepted +but before display updates and input dispatch begin. The hook lets a server +start or locate the authenticated user's session and then replace placeholder +handlers with display/input handlers bound to that session. + +The `ConnectionBinder` API keeps protocol ownership inside IronRDP while +allowing downstream servers to keep user/session lifecycle code outside the RDP +state machine. diff --git a/docs/wrdp/reactivation-credential-cache.md b/docs/wrdp/reactivation-credential-cache.md new file mode 100644 index 000000000..2949076ac --- /dev/null +++ b/docs/wrdp/reactivation-credential-cache.md @@ -0,0 +1,10 @@ +# Reactivation credential cache scope + +During Deactivation-Reactivation some clients do not send a second credentials +PDU. A server that binds display/input handlers after authentication still needs +the identity accepted earlier on the same TCP connection. + +The cache introduced here is deliberately scoped to `accept_finalize()`, i.e. to +one TCP connection. It allows same-connection reactivation to reuse the validated +identity but prevents a new TCP connection from inheriting credentials accepted +on a previous connection.