Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down
1 change: 1 addition & 0 deletions examples/client/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?;
Expand Down
33 changes: 30 additions & 3 deletions src/dialog/authenticate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
/// # }
Expand All @@ -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
Expand All @@ -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")?,
Expand All @@ -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<String>,
/// 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<String>,
}

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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions src/dialog/invitation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/dialog/registration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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));
/// # }
Expand Down
168 changes: 168 additions & 0 deletions src/dialog/tests/test_authenticate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <sip:alice@example.com>;tag=1928301774").into(),
To::new("Bob <sip:bob@example.com>").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;
Expand Down