Sputnik
This library extends the types from the http crate:
- extends
http::request::Parts
with query parameter deserialization & cookie parsing
- extends
http::response::Builder
with methods to set cookies and content-types
If you use Hyper and want to deserialize request bodies
with Serde you can enable the following feature flags:
hyper_body
provides a trait to extend hyper::Body
with an
into_form
method for parsing data submitted from HTML forms.
hyper_body_json
additionaly provides an into_json
method
With the security
feature Sputnik furthermore provides what's necessary to
implement signed & expiring cookies with the
expiry date encoded into the signed cookie value, providing a more
lightweight alternative to JWT if you don't need interoperability.
Sputnik does not handle routing because even complex routing can be quite
easily implemented with nested match
blocks. If you want a more high-level
router, you can check out the router crates.
Sputnik encourages you to create your own error enum and implement From
conversions for every error type, which you want to short-circuit with the ?
operator. This can be easily done with thiserror
because Sputnik restricts its error types to the 'static
lifetime.
Security Considerations
Protect your application against CSRF
by setting SameSite
to Lax
or Strict
for your cookies and checking that the Origin
header matches your domain name (especially if you have unauthenticated POST endpoints).
Hyper Example
use std::convert::Infallible;
use hyper::service::{service_fn, make_service_fn};
use hyper::{Method, Server, StatusCode, Body};
use hyper::http::request::Parts;
use hyper::http::response::Builder;
use serde::Deserialize;
use sputnik::{html_escape, mime, request::SputnikParts, response::SputnikBuilder};
use sputnik::hyper_body::{SputnikBody, FormError};
type Response = hyper::Response<Body>;
#[derive(thiserror::Error, Debug)]
enum Error {
#[error("page not found")]
NotFound(String),
#[error("{0}")]
FormError(#[from] FormError)
}
fn render_error(err: Error) -> (StatusCode, String) {
match err {
Error::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
Error::FormError(err) => (StatusCode::BAD_REQUEST, err.to_string()),
}
}
async fn route(req: &mut Parts, body: Body) -> Result<Response, Error> {
match (&req.method, req.uri.path()) {
(&Method::GET, "/form") => Ok(get_form(req)),
(&Method::POST, "/form") => post_form(req, body).await,
_ => return Err(Error::NotFound("page not found".to_owned()))
}
}
fn get_form(_req: &mut Parts) -> Response {
Builder::new()
.content_type(mime::TEXT_HTML)
.body(
"<form method=post><input name=text> <button>Submit</button></form>".into()
).unwrap()
}
#[derive(Deserialize)]
struct FormData {text: String}
async fn post_form(_req: &mut Parts, body: Body) -> Result<Response, Error> {
let msg: FormData = body.into_form().await?;
Ok(Builder::new().content_type(mime::TEXT_HTML).body(
format!("hello <em>{}</em>", html_escape(msg.text)).into()
).unwrap())
}
async fn service(req: hyper::Request<hyper::Body>) -> Result<hyper::Response<hyper::Body>, Infallible> {
let (mut parts, body) = req.into_parts();
match route(&mut parts, body).await {
Ok(mut res) => {
for (k,v) in parts.response_headers().iter() {
res.headers_mut().append(k, v.clone());
}
Ok(res)
}
Err(err) => {
let (code, message) = render_error(err);
Ok(hyper::Response::builder().status(code).body(message.into()).unwrap())
}
}
}
#[tokio::main]
async fn main() {
let service = make_service_fn(move |_| {
async move {
Ok::<_, hyper::Error>(service_fn(move |req| {
service(req)
}))
}
});
let addr = ([127, 0, 0, 1], 8000).into();
let server = Server::bind(&addr).serve(service);
println!("Listening on http://{}", addr);
server.await;
}
Signed & expiring cookies
After a successful authentication you can build a session id cookie for
example as follows:
let expiry_date = SystemTime::now() + Duration::from_secs(24 * 60 * 60);
let mut cookie = Cookie::new("userid",
key.sign(
&encode_expiring_claim(&userid, expiry_date)
));
headers.set_cookie(Cookie{
name: "userid".into(),
value: key.sign(
&encode_expiring_claim(&userid, expiry_date)
),
secure: Some(true),
expires: Some(expiry_date),
same_site: SameSite::Lax,
});
This session id cookie can then be retrieved and verified as follows:
let userid = req.cookies().find(|(name, _value)| *name == "userid")
.ok_or_else(|| "expected userid cookie".to_owned())
.and_then(|(_name, value)| key.verify(value))
.and_then(|value| decode_expiring_claim(value).map_err(|e| format!("failed to decode userid cookie: {}", e)));
Tip: If you want to store multiple claims in the cookie, you can
(de)serialize a struct with serde_json.
This approach can pose a lightweight alternative to JWT, if you don't care
about the standardization aspect.