diff --git a/src/azure/storage/mod.rs b/src/azure/storage/mod.rs index cbabb00b..f53d6a68 100644 --- a/src/azure/storage/mod.rs +++ b/src/azure/storage/mod.rs @@ -1,10 +1,19 @@ //! Azure Storage Singer mod signer; + pub use signer::Signer as AzureStorageSigner; + mod config; + pub use config::Config as AzureStorageConfig; + mod credential; + pub use credential::Credential as AzureStorageCredential; + mod loader; + pub use loader::Loader as AzureStorageLoader; + +mod sas; diff --git a/src/azure/storage/sas/account_sas.rs b/src/azure/storage/sas/account_sas.rs new file mode 100644 index 00000000..6b5654c9 --- /dev/null +++ b/src/azure/storage/sas/account_sas.rs @@ -0,0 +1,123 @@ +use crate::time::DateTime; +use crate::{hash, time}; + +/// The default parameters that make up a SAS token +/// https://learn.microsoft.com/en-us/rest/api/storageservices/create-account-sas#specify-the-account-sas-parameters +const ACCOUNT_SAS_VERSION: &str = "2018-11-09"; +const ACCOUNT_SAS_RESOURCE: &str = "bqtf"; +const ACCOUNT_SAS_RESOURCE_TYPE: &str = "sco"; +const ACCOUNT_SAS_PERMISSIONS: &str = "rwdlacu"; + +pub struct AccountSharedAccessSignature { + account: String, + key: String, + version: String, + resource: String, + resource_type: String, + permissions: String, + expiry: DateTime, + start: Option, + ip: Option, + protocol: Option, +} + +impl AccountSharedAccessSignature { + /// Create a SAS token signer with default parameters + pub fn new(account: String, key: String, expiry: DateTime) -> Self { + Self { + account, + key, + expiry, + start: None, + ip: None, + protocol: None, + version: ACCOUNT_SAS_VERSION.to_string(), + resource: ACCOUNT_SAS_RESOURCE.to_string(), + resource_type: ACCOUNT_SAS_RESOURCE_TYPE.to_string(), + permissions: ACCOUNT_SAS_PERMISSIONS.to_string(), + } + } + + // Azure documentation: https://learn.microsoft.com/en-us/rest/api/storageservices/create-account-sas#construct-the-signature-string + fn signature(&self) -> String { + let string_to_sign = format!( + "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n", + self.account, + self.permissions, + self.resource, + self.resource_type, + self.start + .as_ref() + .map_or("".to_string(), |v| urlencoded(time::format_rfc3339(*v))), + time::format_rfc3339(self.expiry), + self.ip.clone().unwrap_or_default(), + self.protocol + .as_ref() + .map_or("".to_string(), |v| v.to_string()), + self.version, + ); + + hash::base64_hmac_sha256( + &hash::base64_decode(self.key.clone().as_str()), + string_to_sign.as_bytes(), + ) + } + + /// [Example](https://docs.microsoft.com/rest/api/storageservices/create-service-sas#service-sas-example) from Azure documentation. + pub fn token(&self) -> Vec<(String, String)> { + let mut elements: Vec<(String, String)> = vec![ + ("sv".to_string(), self.version.to_string()), + ("ss".to_string(), self.resource.to_string()), + ("srt".to_string(), self.resource_type.to_string()), + ( + "se".to_string(), + urlencoded(time::format_rfc3339(self.expiry)), + ), + ("sp".to_string(), self.permissions.to_string()), + ]; + + if let Some(start) = &self.start { + elements.push(("st".to_string(), urlencoded(time::format_rfc3339(*start)))) + } + if let Some(ip) = &self.ip { + elements.push(("sip".to_string(), ip.to_string())) + } + if let Some(protocol) = &self.protocol { + elements.push(("spr".to_string(), protocol.to_string())) + } + + let sig = AccountSharedAccessSignature::signature(self); + elements.push(("sig".to_string(), urlencoded(sig))); + + elements + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + fn test_time() -> DateTime { + DateTime::from_str("2022-03-01T08:12:34Z").unwrap() + } + + #[test] + fn test_can_generate_sas_token() { + let key = hash::base64_encode("key".as_bytes()); + let expiry = test_time() + chrono::Duration::minutes(5); + let sign = AccountSharedAccessSignature::new("account".to_string(), key, expiry); + let token = sign + .token() + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join("&"); + + assert_eq!(token, "sv=2018-11-09&ss=bqtf&srt=sco&se=2022-03-01T08%3A17%3A34Z&sp=rwdlacu&sig=jgK9nDUT0ntH%2Fp28LPs0jzwxsk91W6hePLPlfrElv4k%3D"); + } +} + +fn urlencoded(s: String) -> String { + form_urlencoded::byte_serialize(s.as_bytes()).collect() +} diff --git a/src/azure/storage/sas/mod.rs b/src/azure/storage/sas/mod.rs new file mode 100644 index 00000000..21340e74 --- /dev/null +++ b/src/azure/storage/sas/mod.rs @@ -0,0 +1 @@ +pub mod account_sas; diff --git a/src/azure/storage/signer.rs b/src/azure/storage/signer.rs index 06cb49aa..6c8d88a0 100644 --- a/src/azure/storage/signer.rs +++ b/src/azure/storage/signer.rs @@ -4,13 +4,13 @@ use std::fmt::Debug; use std::fmt::Write; use std::time::Duration; -use anyhow::anyhow; use anyhow::Result; use http::header::*; use log::debug; use super::super::constants::*; use super::credential::Credential; +use crate::azure::storage::sas::account_sas; use crate::ctx::SigningContext; use crate::ctx::SigningMethod; use crate::hash::base64_decode; @@ -68,8 +68,17 @@ impl Signer { return Ok(ctx); } Credential::SharedKey(ak, sk) => match method { - SigningMethod::Query(_) => { - return Err(anyhow!("SAS token is required for query signing")); + SigningMethod::Query(d) => { + // try sign request use account_sas token + let signer = account_sas::AccountSharedAccessSignature::new( + ak.to_string(), + sk.to_string(), + time::now() + chrono::Duration::from_std(d)?, + ); + + signer.token().iter().for_each(|(k, v)| { + ctx.query_push(k, v); + }); } SigningMethod::Header => { let now = self.time.unwrap_or_else(time::now); @@ -127,8 +136,13 @@ impl Signer { } /// Signing request with query. - pub fn sign_query(&self, req: &mut impl SignableRequest, cred: &Credential) -> Result<()> { - let ctx = self.build(req, SigningMethod::Query(Duration::from_secs(1)), cred)?; + pub fn sign_query( + &self, + req: &mut impl SignableRequest, + expire: Duration, + cred: &Credential, + ) -> Result<()> { + let ctx = self.build(req, SigningMethod::Query(expire), cred)?; req.apply(ctx) } } @@ -247,6 +261,7 @@ fn canonicalize_resource(ctx: &mut SigningContext, ak: &str) -> String { #[cfg(test)] mod tests { use http::Request; + use std::time::Duration; use super::super::config::Config; use crate::azure::storage::loader::Loader; @@ -273,7 +288,9 @@ mod tests { .unwrap(); // Signing request with Signer - assert!(signer.sign_query(&mut req, &cred).is_ok()); + assert!(signer + .sign_query(&mut req, Duration::from_secs(1), &cred) + .is_ok()); assert_eq!(req.uri(), "https://test.blob.core.windows.net/testbucket/testblob?sv=2021-01-01&ss=b&srt=c&sp=rwdlaciytfx&se=2022-01-01T11:00:14Z&st=2022-01-02T03:00:14Z&spr=https&sig=KEllk4N8f7rJfLjQCmikL2fRVt%2B%2Bl73UBkbgH%2FK3VGE%3D") } } diff --git a/tests/azure/storage.rs b/tests/azure/storage.rs index 103c411b..a33caa5c 100644 --- a/tests/azure/storage.rs +++ b/tests/azure/storage.rs @@ -1,5 +1,6 @@ use std::env; use std::str::FromStr; +use std::time::Duration; use anyhow::Result; use http::StatusCode; @@ -7,9 +8,8 @@ use log::debug; use log::warn; use percent_encoding::utf8_percent_encode; use percent_encoding::NON_ALPHANUMERIC; -use reqsign::AzureStorageConfig; -use reqsign::AzureStorageLoader; use reqsign::AzureStorageSigner; +use reqsign::{AzureStorageConfig, AzureStorageLoader}; use reqwest::Client; fn init_signer() -> Option<(AzureStorageLoader, AzureStorageSigner)> { @@ -122,7 +122,7 @@ async fn test_head_object_with_encoded_characters() -> Result<()> { } #[tokio::test] -async fn test_list_blobs() -> Result<()> { +async fn test_list_container_blobs() -> Result<()> { let signer = init_signer(); if signer.is_none() { warn!("REQSIGN_AZURE_STORAGE_ON_TEST is not set, skipped"); @@ -169,3 +169,90 @@ async fn test_list_blobs() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn test_can_head_blob_with_sas() -> Result<()> { + let signer = init_signer(); + if signer.is_none() { + warn!("REQSIGN_AZURE_STORAGE_ON_TEST is not set, skipped"); + return Ok(()); + } + let (loader, signer) = signer.unwrap(); + + let url = + &env::var("REQSIGN_AZURE_STORAGE_URL").expect("env REQSIGN_AZURE_STORAGE_URL must set"); + + let mut builder = http::Request::builder(); + builder = builder.method(http::Method::HEAD); + builder = builder.uri(format!("{}/{}", url, "not_exist_file")); + let mut req = builder.body("")?; + + let cred = loader + .load() + .await + .expect("load credential must success") + .unwrap(); + signer + .sign_query(&mut req, Duration::from_secs(60), &cred) + .expect("sign request must success"); + + println!("signed request: {:?}", req); + + let client = Client::new(); + let resp = client + .execute(req.try_into()?) + .await + .expect("request must success"); + + println!("got response: {:?}", resp); + assert_eq!(StatusCode::NOT_FOUND, resp.status()); + Ok(()) +} + +#[tokio::test] +async fn test_can_list_container_blobs() -> Result<()> { + // API https://learn.microsoft.com/en-us/rest/api/storageservices/list-blobs?tabs=azure-ad + let signer = init_signer(); + if signer.is_none() { + warn!("REQSIGN_AZURE_STORAGE_ON_TEST is not set, skipped"); + return Ok(()); + } + let (loader, signer) = signer.unwrap(); + + let url = + &env::var("REQSIGN_AZURE_STORAGE_URL").expect("env REQSIGN_AZURE_STORAGE_URL must set"); + + for query in [ + // Without prefix + "restype=container&comp=list", + // With not encoded prefix + "restype=container&comp=list&prefix=test/path/to/dir", + // With encoded prefix + "restype=container&comp=list&prefix=test%2Fpath%2Fto%2Fdir", + ] { + let mut builder = http::Request::builder(); + builder = builder.method(http::Method::GET); + builder = builder.uri(format!("{url}?{query}")); + let mut req = builder.body("")?; + + let cred = loader + .load() + .await + .expect("load credential must success") + .unwrap(); + signer + .sign_query(&mut req, Duration::from_secs(60), &cred) + .expect("sign request must success"); + + let client = Client::new(); + let resp = client + .execute(req.try_into()?) + .await + .expect("request must success"); + + debug!("got response: {:?}", resp); + assert_eq!(StatusCode::OK, resp.status()); + } + + Ok(()) +}