Skip to main content

rainier_framework/
signed.rs

1//! Signed routes — [`SignedUrls`] and [`ValidateSignature`].
2//!
3//! ```ignore
4//! // Generating one, from a named route.
5//! let link = signed.route("unsubscribe", &[("user", "42")])?;
6//! let link = signed.temporary_route("verify-email", expires_at, &[("user", "42")])?;
7//!
8//! // Checking it.
9//! router.get("/verify-email", verify).name("verify-email")
10//!     .middleware(ValidateSignature::resolved());
11//! ```
12//!
13//! Laravel's `URL::signedRoute` and its `signed` middleware. The point is what
14//! it removes: an unsubscribe link, a verification link or a one-time download
15//! needs no token table, no lookup and no sweep job, because the URL carries
16//! its own proof.
17//!
18//! Read [`UrlSigner`] for what is signed and — more importantly — for the two
19//! things a signature is not: single-use, and secret.
20
21use std::sync::Arc;
22
23use rainier_crypt::UrlSigner;
24use rainier_http::{IntoResponse, Request, Response};
25use rainier_middleware::{Middleware, MiddlewareStack, Next};
26use rainier_routing::UrlGenerator;
27use rainier_support::Result;
28
29/// Signed URLs for this application's named routes.
30///
31/// Bound at boot from the same key ring everything else uses, and composed
32/// with the [`UrlGenerator`] so a signed link is built from a **route name**
33/// rather than a path somebody typed twice.
34pub struct SignedUrls {
35    urls: Arc<UrlGenerator>,
36    signer: Arc<UrlSigner>,
37}
38
39impl SignedUrls {
40    /// Sign links to the routes `urls` knows about.
41    pub fn new(urls: Arc<UrlGenerator>, signer: Arc<UrlSigner>) -> Self {
42        Self { urls, signer }
43    }
44
45    /// A signed link to a named route.
46    ///
47    /// ```ignore
48    /// signed.route("unsubscribe", &[("user", "42")])?
49    /// // /unsubscribe/42?signature=…
50    /// ```
51    ///
52    /// Route parameters are substituted into the path as usual; anything the
53    /// route does not name becomes a query parameter, and is signed with the
54    /// rest.
55    pub fn route(&self, name: &str, params: &[(&str, &str)]) -> Result<String> {
56        self.signer.sign(&self.urls.route(name, params)?)
57    }
58
59    /// A signed link that stops working after `expires_at`.
60    ///
61    /// `expires_at` is seconds since the epoch. It is part of the query and so
62    /// part of the signature: moving it invalidates the link.
63    ///
64    /// This is the one to reach for. An unsigned expiry is a promise; a signed
65    /// one is a fact, and a link with no expiry at all lives in a mailbox for
66    /// years.
67    pub fn temporary_route(
68        &self,
69        name: &str,
70        expires_at: i64,
71        params: &[(&str, &str)],
72    ) -> Result<String> {
73        self.signer.sign_until(&self.urls.route(name, params)?, expires_at)
74    }
75
76    /// A signed absolute URL — with the scheme and host in front.
77    ///
78    /// What goes in an email. Note the host is **not** covered by the
79    /// signature; see [`UrlSigner`].
80    pub fn absolute_route(&self, name: &str, params: &[(&str, &str)]) -> Result<String> {
81        let absolute = self.urls.absolute(name, params)?;
82        self.signer.sign(&absolute)
83    }
84
85    /// [`absolute_route`](Self::absolute_route), expiring.
86    pub fn temporary_absolute_route(
87        &self,
88        name: &str,
89        expires_at: i64,
90        params: &[(&str, &str)],
91    ) -> Result<String> {
92        let absolute = self.urls.absolute(name, params)?;
93        self.signer.sign_until(&absolute, expires_at)
94    }
95
96    /// Sign a path directly, for a link no named route describes.
97    pub fn sign(&self, url: &str) -> Result<String> {
98        self.signer.sign(url)
99    }
100
101    /// The signer underneath.
102    pub fn signer(&self) -> &Arc<UrlSigner> {
103        &self.signer
104    }
105}
106
107impl std::fmt::Debug for SignedUrls {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.write_str("SignedUrls")
110    }
111}
112
113/// Refuses a request whose URL is not signed by this application.
114///
115/// ```ignore
116/// router.get("/unsubscribe", unsubscribe).middleware(ValidateSignature::resolved());
117/// ```
118///
119/// Answers `403` for a missing, forged or expired signature — and says which,
120/// because "this link has expired" and "this link is not valid" send the
121/// reader to different places.
122pub struct ValidateSignature {
123    signer: Arc<UrlSigner>,
124}
125
126impl ValidateSignature {
127    /// Check against `signer`.
128    pub fn new(signer: Arc<UrlSigner>) -> Self {
129        Self { signer }
130    }
131
132    /// Check against the signer in the container.
133    ///
134    /// The usual form, and the reason it is a stack: routes are declared
135    /// before the container exists.
136    pub fn resolved() -> MiddlewareStack {
137        MiddlewareStack::new().resolved(|signer: Arc<UrlSigner>| Self::new(signer))
138    }
139}
140
141#[async_trait::async_trait]
142impl Middleware for ValidateSignature {
143    async fn handle(&self, request: Request, next: Next) -> Response {
144        // Path and query only. The host is deliberately outside the signature,
145        // and including it here would check something that was never signed.
146        let url = match request.uri().query() {
147            Some(query) => format!("{}?{}", request.uri().path(), query),
148            None => request.uri().path().to_string(),
149        };
150
151        match self.signer.verify(&url) {
152            Ok(()) => next.run(request).await,
153            Err(e) => e.into_response(),
154        }
155    }
156
157    fn name(&self) -> &'static str {
158        "ValidateSignature"
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use rainier_crypt::{Key, KeyRing};
166    use rainier_http::{Method, StatusCode};
167    use rainier_middleware::Pipeline;
168
169    fn signer() -> Arc<UrlSigner> {
170        Arc::new(UrlSigner::new(KeyRing::new(Key::generate())))
171    }
172
173    fn urls() -> Arc<UrlGenerator> {
174        Arc::new(UrlGenerator::from_routes([
175            ("unsubscribe".to_string(), "/unsubscribe/{user}".to_string()),
176            ("verify".to_string(), "/verify".to_string()),
177        ]))
178    }
179
180    fn in_an_hour() -> i64 {
181        std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64
182            + 3600
183    }
184
185    async fn through(signer: Arc<UrlSigner>, url: &str) -> StatusCode {
186        Pipeline::new()
187            .through(ValidateSignature::new(signer))
188            .then(|_| async { Response::ok("followed") })
189            .run(Request::builder().method(Method::GET).uri(url).build())
190            .await
191            .status()
192    }
193
194    #[tokio::test]
195    async fn a_signed_route_is_followed() {
196        let signer = signer();
197        let signed = SignedUrls::new(urls(), Arc::clone(&signer));
198
199        let link = signed.route("unsubscribe", &[("user", "42")]).unwrap();
200
201        assert!(link.starts_with("/unsubscribe/42?"), "{link}");
202        assert_eq!(through(signer, &link).await, StatusCode::OK);
203    }
204
205    #[tokio::test]
206    async fn an_unsigned_request_to_the_same_route_is_refused() {
207        assert_eq!(through(signer(), "/unsubscribe/42").await, StatusCode::FORBIDDEN);
208    }
209
210    #[tokio::test]
211    async fn tampering_with_the_id_is_refused() {
212        // The attack this exists to stop: unsubscribing somebody else.
213        let signer = signer();
214        let signed = SignedUrls::new(urls(), Arc::clone(&signer));
215
216        let link = signed.route("unsubscribe", &[("user", "42")]).unwrap();
217        let tampered = link.replace("/unsubscribe/42", "/unsubscribe/43");
218
219        assert_eq!(through(signer, &tampered).await, StatusCode::FORBIDDEN);
220    }
221
222    #[tokio::test]
223    async fn an_expired_link_is_refused_and_says_so() {
224        let signer = signer();
225        let signed = SignedUrls::new(urls(), Arc::clone(&signer));
226
227        let expired = signed.temporary_route("verify", 1, &[("user", "42")]).unwrap();
228
229        let response = Pipeline::new()
230            .through(ValidateSignature::new(signer))
231            .then(|_| async { Response::ok("followed") })
232            .run(Request::builder().method(Method::GET).uri(&expired).build())
233            .await;
234
235        assert_eq!(response.status(), StatusCode::FORBIDDEN);
236        // Which one it is matters: an expired link deserves "here is a new
237        // one", and a forged one does not.
238        assert!(response.into_string().await.unwrap().contains("expired"));
239    }
240
241    #[tokio::test]
242    async fn a_live_temporary_link_is_followed() {
243        let signer = signer();
244        let signed = SignedUrls::new(urls(), Arc::clone(&signer));
245
246        let link = signed.temporary_route("verify", in_an_hour(), &[("user", "42")]).unwrap();
247
248        assert!(link.contains("expires="));
249        assert_eq!(through(signer, &link).await, StatusCode::OK);
250    }
251
252    #[tokio::test]
253    async fn an_absolute_link_verifies_by_its_path() {
254        // What goes in an email. The middleware sees only the path and query,
255        // which is exactly what was signed.
256        let signer = signer();
257        let signed = SignedUrls::new(
258            Arc::new(
259                UrlGenerator::from_routes([("verify".to_string(), "/verify".to_string())])
260                    .with_base("https://app.example.com"),
261            ),
262            Arc::clone(&signer),
263        );
264
265        let absolute = signed.absolute_route("verify", &[("user", "42")]).unwrap();
266        assert!(absolute.starts_with("https://app.example.com/verify"), "{absolute}");
267
268        let path = absolute.trim_start_matches("https://app.example.com");
269        assert_eq!(through(signer, path).await, StatusCode::OK);
270    }
271
272    #[tokio::test]
273    async fn an_unknown_route_name_is_an_error_rather_than_an_unsigned_link() {
274        let signed = SignedUrls::new(urls(), signer());
275
276        assert!(signed.route("nope", &[]).is_err());
277    }
278}