use std::sync::Arc;
use rainier_crypt::UrlSigner;
use rainier_http::{IntoResponse, Request, Response};
use rainier_middleware::{Middleware, MiddlewareStack, Next};
use rainier_routing::UrlGenerator;
use rainier_support::Result;
pub struct SignedUrls {
urls: Arc<UrlGenerator>,
signer: Arc<UrlSigner>,
}
impl SignedUrls {
pub fn new(urls: Arc<UrlGenerator>, signer: Arc<UrlSigner>) -> Self {
Self { urls, signer }
}
pub fn route(&self, name: &str, params: &[(&str, &str)]) -> Result<String> {
self.signer.sign(&self.urls.route(name, params)?)
}
pub fn temporary_route(
&self,
name: &str,
expires_at: i64,
params: &[(&str, &str)],
) -> Result<String> {
self.signer.sign_until(&self.urls.route(name, params)?, expires_at)
}
pub fn absolute_route(&self, name: &str, params: &[(&str, &str)]) -> Result<String> {
let absolute = self.urls.absolute(name, params)?;
self.signer.sign(&absolute)
}
pub fn temporary_absolute_route(
&self,
name: &str,
expires_at: i64,
params: &[(&str, &str)],
) -> Result<String> {
let absolute = self.urls.absolute(name, params)?;
self.signer.sign_until(&absolute, expires_at)
}
pub fn sign(&self, url: &str) -> Result<String> {
self.signer.sign(url)
}
pub fn signer(&self) -> &Arc<UrlSigner> {
&self.signer
}
}
impl std::fmt::Debug for SignedUrls {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SignedUrls")
}
}
pub struct ValidateSignature {
signer: Arc<UrlSigner>,
}
impl ValidateSignature {
pub fn new(signer: Arc<UrlSigner>) -> Self {
Self { signer }
}
pub fn resolved() -> MiddlewareStack {
MiddlewareStack::new().resolved(|signer: Arc<UrlSigner>| Self::new(signer))
}
}
#[async_trait::async_trait]
impl Middleware for ValidateSignature {
async fn handle(&self, request: Request, next: Next) -> Response {
let url = match request.uri().query() {
Some(query) => format!("{}?{}", request.uri().path(), query),
None => request.uri().path().to_string(),
};
match self.signer.verify(&url) {
Ok(()) => next.run(request).await,
Err(e) => e.into_response(),
}
}
fn name(&self) -> &'static str {
"ValidateSignature"
}
}
#[cfg(test)]
mod tests {
use super::*;
use rainier_crypt::{Key, KeyRing};
use rainier_http::{Method, StatusCode};
use rainier_middleware::Pipeline;
fn signer() -> Arc<UrlSigner> {
Arc::new(UrlSigner::new(KeyRing::new(Key::generate())))
}
fn urls() -> Arc<UrlGenerator> {
Arc::new(UrlGenerator::from_routes([
("unsubscribe".to_string(), "/unsubscribe/{user}".to_string()),
("verify".to_string(), "/verify".to_string()),
]))
}
fn in_an_hour() -> i64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64
+ 3600
}
async fn through(signer: Arc<UrlSigner>, url: &str) -> StatusCode {
Pipeline::new()
.through(ValidateSignature::new(signer))
.then(|_| async { Response::ok("followed") })
.run(Request::builder().method(Method::GET).uri(url).build())
.await
.status()
}
#[tokio::test]
async fn a_signed_route_is_followed() {
let signer = signer();
let signed = SignedUrls::new(urls(), Arc::clone(&signer));
let link = signed.route("unsubscribe", &[("user", "42")]).unwrap();
assert!(link.starts_with("/unsubscribe/42?"), "{link}");
assert_eq!(through(signer, &link).await, StatusCode::OK);
}
#[tokio::test]
async fn an_unsigned_request_to_the_same_route_is_refused() {
assert_eq!(through(signer(), "/unsubscribe/42").await, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn tampering_with_the_id_is_refused() {
let signer = signer();
let signed = SignedUrls::new(urls(), Arc::clone(&signer));
let link = signed.route("unsubscribe", &[("user", "42")]).unwrap();
let tampered = link.replace("/unsubscribe/42", "/unsubscribe/43");
assert_eq!(through(signer, &tampered).await, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn an_expired_link_is_refused_and_says_so() {
let signer = signer();
let signed = SignedUrls::new(urls(), Arc::clone(&signer));
let expired = signed.temporary_route("verify", 1, &[("user", "42")]).unwrap();
let response = Pipeline::new()
.through(ValidateSignature::new(signer))
.then(|_| async { Response::ok("followed") })
.run(Request::builder().method(Method::GET).uri(&expired).build())
.await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert!(response.into_string().await.unwrap().contains("expired"));
}
#[tokio::test]
async fn a_live_temporary_link_is_followed() {
let signer = signer();
let signed = SignedUrls::new(urls(), Arc::clone(&signer));
let link = signed.temporary_route("verify", in_an_hour(), &[("user", "42")]).unwrap();
assert!(link.contains("expires="));
assert_eq!(through(signer, &link).await, StatusCode::OK);
}
#[tokio::test]
async fn an_absolute_link_verifies_by_its_path() {
let signer = signer();
let signed = SignedUrls::new(
Arc::new(
UrlGenerator::from_routes([("verify".to_string(), "/verify".to_string())])
.with_base("https://app.example.com"),
),
Arc::clone(&signer),
);
let absolute = signed.absolute_route("verify", &[("user", "42")]).unwrap();
assert!(absolute.starts_with("https://app.example.com/verify"), "{absolute}");
let path = absolute.trim_start_matches("https://app.example.com");
assert_eq!(through(signer, path).await, StatusCode::OK);
}
#[tokio::test]
async fn an_unknown_route_name_is_an_error_rather_than_an_unsigned_link() {
let signed = SignedUrls::new(urls(), signer());
assert!(signed.route("nope", &[]).is_err());
}
}