diff --git a/README.md b/README.md index c9289699..9ae9d262 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,7 @@ let credential = Credential { username: "alice".to_string(), password: "secret".to_string(), realm: None, + auth_username: None, }; let mut registration = Registration::new(endpoint.inner.clone(), Some(credential.clone())); diff --git a/examples/client/main.rs b/examples/client/main.rs index 9589ea47..81181788 100644 --- a/examples/client/main.rs +++ b/examples/client/main.rs @@ -202,6 +202,7 @@ async fn main() -> rsipstack::Result<()> { username: sip_username.clone(), password: sip_password, realm: None, + auth_username: None, }; let incoming = endpoint.incoming_transactions()?; diff --git a/src/dialog/authenticate.rs b/src/dialog/authenticate.rs index f5dd2b74..524d1576 100644 --- a/src/dialog/authenticate.rs +++ b/src/dialog/authenticate.rs @@ -33,6 +33,7 @@ use crate::Result; /// username: "alice".to_string(), /// password: "secret123".to_string(), /// realm: Some("example.com".to_string()), +/// auth_username: None, /// }; /// # Ok(()) /// # } @@ -47,6 +48,7 @@ use crate::Result; /// username: "alice".to_string(), /// password: "secret123".to_string(), /// realm: None, // Will be extracted from server challenge +/// auth_username: None, /// }; /// /// // Use credential with registration @@ -66,6 +68,7 @@ use crate::Result; /// # username: "alice".to_string(), /// # password: "secret123".to_string(), /// # realm: Some("example.com".to_string()), +/// # auth_username: None, /// # }; /// let invite_option = InviteOption { /// caller: rsipstack::sip::Uri::try_from("sip:alice@example.com")?, @@ -79,11 +82,33 @@ use crate::Result; /// # Ok(()) /// # } /// ``` -#[derive(Clone)] +#[derive(Clone, Default)] pub struct Credential { + /// The user part of the AOR (From/To/Contact of REGISTER, local contact + /// of dialogs). Also the digest username unless `auth_username` is set. pub username: String, pub password: String, pub realm: Option, + /// Digest authentication username, when it differs from `username`. + /// + /// Some PBXs (3CX, Asterisk `auth` objects, …) hand out an + /// "Authentication ID" that is distinct from the extension: the AOR is + /// `sip:01@pbx` but the `username=` in the Authorization / + /// Proxy-Authorization header (and the digest) must be that ID. + /// `None` (or an empty string) keeps the classic behavior of + /// authenticating as `username`. + pub auth_username: Option, +} + +impl Credential { + /// The name used for digest authentication: `auth_username` when set + /// and non-empty, otherwise `username`. + pub fn digest_username(&self) -> &str { + match self.auth_username.as_deref() { + Some(name) if !name.is_empty() => name, + _ => self.username.as_str(), + } + } } /// Handle client-side authentication challenge @@ -120,6 +145,7 @@ pub struct Credential { /// # username: "alice".to_string(), /// # password: "secret123".to_string(), /// # realm: Some("example.com".to_string()), +/// # auth_username: None, /// # }; /// // This is typically called automatically by dialog methods /// let new_tx = handle_client_authenticate( @@ -147,6 +173,7 @@ pub struct Credential { /// # username: "alice".to_string(), /// # password: "secret123".to_string(), /// # realm: Some("example.com".to_string()), +/// # auth_username: None, /// # }; /// # let new_seq = 2u32; /// // Send initial request @@ -262,7 +289,7 @@ pub async fn handle_client_authenticate( .unwrap_or(crate::sip::headers::auth::Algorithm::Md5); let response = DigestGenerator { - username: cred.username.as_str(), + username: cred.digest_username(), password: cred.password.as_str(), algorithm, nonce: challenge.nonce.as_str(), @@ -275,7 +302,7 @@ pub async fn handle_client_authenticate( let auth = Authorization { scheme: challenge.scheme, - username: cred.username.clone(), + username: cred.digest_username().to_string(), realm: challenge.realm, nonce: challenge.nonce, uri: tx.original.uri.clone(), diff --git a/src/dialog/invitation.rs b/src/dialog/invitation.rs index babc25a8..9430771a 100644 --- a/src/dialog/invitation.rs +++ b/src/dialog/invitation.rs @@ -113,6 +113,7 @@ use tracing::{debug, info, warn}; /// username: "alice".to_string(), /// password: "secret123".to_string(), /// realm: Some("example.com".to_string()), +/// auth_username: None, /// }; /// /// let invite_option = InviteOption { diff --git a/src/dialog/registration.rs b/src/dialog/registration.rs index 6a85c922..a3af5702 100644 --- a/src/dialog/registration.rs +++ b/src/dialog/registration.rs @@ -53,6 +53,7 @@ use tracing::debug; /// username: "alice".to_string(), /// password: "secret123".to_string(), /// realm: Some("example.com".to_string()), +/// auth_username: None, /// }; /// /// let mut registration = Registration::new(endpoint.inner.clone(), Some(credential)); @@ -155,6 +156,7 @@ impl Registration { /// username: "alice".to_string(), /// password: "secret123".to_string(), /// realm: Some("example.com".to_string()), + /// auth_username: None, /// }; /// let registration = Registration::new(endpoint.inner.clone(), Some(credential)); /// # } diff --git a/src/dialog/tests/test_authenticate.rs b/src/dialog/tests/test_authenticate.rs index d09b85f1..ab0a9f85 100644 --- a/src/dialog/tests/test_authenticate.rs +++ b/src/dialog/tests/test_authenticate.rs @@ -103,6 +103,7 @@ async fn test_authenticate_via_header_branch_update() -> crate::Result<()> { username: "alice".to_string(), password: "secret123".to_string(), realm: None, + auth_username: None, }; // Call handle_client_authenticate @@ -157,6 +158,173 @@ async fn test_authenticate_via_header_branch_update() -> crate::Result<()> { Ok(()) } +fn create_407_response() -> Response { + Response { + status_code: StatusCode::ProxyAuthenticationRequired, + version: crate::sip::Version::V2, + headers: vec![ + Via::new("SIP/2.0/UDP alice.example.com:5060;branch=z9hG4bKnashds").into(), + CSeq::new("1 REGISTER").into(), + From::new("Alice ;tag=1928301774").into(), + To::new("Bob ").into(), + CallId::new("a84b4c76e66710@pc33.atlanta.com").into(), + ProxyAuthenticate::new( + r#"Digest realm="proxy.example.com", nonce="f84f1cec41e6cbe5aea9c8e88d359", algorithm=MD5, qop="auth""#, + ) + .into(), + ] + .into(), + body: vec![], + } +} + +#[test] +fn test_credential_digest_username_defaults_to_username() { + let cred = Credential { + username: "alice".to_string(), + password: "secret123".to_string(), + realm: None, + auth_username: None, + }; + assert_eq!(cred.digest_username(), "alice"); + + // An empty auth username is treated as "not set". + let cred = Credential { + auth_username: Some(String::new()), + ..cred + }; + assert_eq!(cred.digest_username(), "alice"); + + let cred = Credential { + auth_username: Some("auth-id".to_string()), + ..cred + }; + assert_eq!(cred.digest_username(), "auth-id"); +} + +/// Extract the (raw value, typed) Authorization or Proxy-Authorization +/// header the client added in response to a challenge. +fn authorization_of(req: &Request) -> (String, crate::sip::typed::Authorization) { + let raw = req + .headers + .iter() + .find_map(|h| match h { + Header::Authorization(a) => Some(a.value().to_string()), + Header::ProxyAuthorization(a) => Some(a.value().to_string()), + _ => None, + }) + .expect("request should carry an Authorization/Proxy-Authorization header"); + let typed = crate::sip::typed::Authorization::parse(&raw).expect("parseable digest header"); + (raw, typed) +} + +/// A PBX "Authentication ID" (3CX, Asterisk `auth` objects, …) is distinct +/// from the extension: the AOR user stays `username`, but the digest must +/// be computed with — and the header must carry — `auth_username`. +#[tokio::test] +async fn test_handle_client_authenticate_uses_auth_username() -> crate::Result<()> { + use crate::dialog::authenticate::verify_digest; + use crate::sip::Method; + + let endpoint = create_test_endpoint().await?; + let original_req = create_request_with_branch("z9hG4bKnashds"); + let key = TransactionKey::from_request(&original_req, TransactionRole::Client)?; + let tx = Transaction::new_client(key, original_req, endpoint.inner.clone(), None); + + let cred = Credential { + username: "01".to_string(), + password: "secret123".to_string(), + realm: None, + auth_username: Some("ksFgqXyZ".to_string()), + }; + let new_tx = handle_client_authenticate(2, &tx, create_401_response(), &cred).await?; + + let (raw, auth) = authorization_of(&new_tx.original); + assert_eq!( + auth.username, "ksFgqXyZ", + "header must carry the auth username" + ); + assert!( + verify_digest(&auth, "secret123", &Method::Register, &raw), + "digest must be computed with the auth username" + ); + + // The AOR user must not leak into the digest: the same challenge answered + // as `username` produces a different response. + let auth_as_aor_user = crate::sip::typed::Authorization { + username: "01".to_string(), + ..auth.clone() + }; + assert!( + !verify_digest(&auth_as_aor_user, "secret123", &Method::Register, &raw), + "the digest must differ from one computed with the AOR user" + ); + + // From/To (the AOR) are untouched by authentication. + let from = new_tx.original.from_header()?.typed()?; + assert_eq!( + from.uri.auth.as_ref().map(|a| a.user.as_str()), + Some("alice") + ); + Ok(()) +} + +/// Same for a 407 from a proxy: the Proxy-Authorization header carries the +/// auth username. +#[tokio::test] +async fn test_handle_client_proxy_authenticate_uses_auth_username() -> crate::Result<()> { + use crate::dialog::authenticate::verify_digest; + use crate::sip::Method; + + let endpoint = create_test_endpoint().await?; + let original_req = create_request_with_branch("z9hG4bKnashds"); + let key = TransactionKey::from_request(&original_req, TransactionRole::Client)?; + let tx = Transaction::new_client(key, original_req, endpoint.inner.clone(), None); + + let cred = Credential { + username: "01".to_string(), + password: "secret123".to_string(), + realm: None, + auth_username: Some("ksFgqXyZ".to_string()), + }; + let new_tx = handle_client_authenticate(2, &tx, create_407_response(), &cred).await?; + + assert!( + new_tx + .original + .headers + .iter() + .any(|h| matches!(h, Header::ProxyAuthorization(_))), + "a 407 must be answered with Proxy-Authorization" + ); + let (raw, auth) = authorization_of(&new_tx.original); + assert_eq!(auth.username, "ksFgqXyZ"); + assert_eq!(auth.realm, "proxy.example.com"); + assert!(verify_digest(&auth, "secret123", &Method::Register, &raw)); + Ok(()) +} + +/// Without `auth_username` the behavior is unchanged: the digest username +/// is the AOR user. +#[tokio::test] +async fn test_handle_client_authenticate_without_auth_username() -> crate::Result<()> { + let endpoint = create_test_endpoint().await?; + let original_req = create_request_with_branch("z9hG4bKnashds"); + let key = TransactionKey::from_request(&original_req, TransactionRole::Client)?; + let tx = Transaction::new_client(key, original_req, endpoint.inner.clone(), None); + + let cred = Credential { + username: "alice".to_string(), + password: "secret123".to_string(), + realm: None, + auth_username: None, + }; + let new_tx = handle_client_authenticate(2, &tx, create_401_response(), &cred).await?; + let (_, auth) = authorization_of(&new_tx.original); + assert_eq!(auth.username, "alice"); + Ok(()) +} + #[test] fn test_extract_digest_uri_raw() { use crate::dialog::authenticate::extract_digest_uri_raw;