aboutsummaryrefslogtreecommitdiff
path: root/README.md
blob: 2b006e69129f3aa647f06bd1552df42625bd139b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# Sputnik

A lightweight layer on top of [Hyper](https://hyper.rs/) to facilitate
building web applications.

Sputnik provides:

* convenience wrappers around hyper's `Request` & `Response`
    * parse, set and delete cookies
      (powered by the [cookie](https://crates.io/crates/cookie) crate)
    * parse query strings and HTML form data (powered by the
      [serde_urlencoded](https://crates.io/crates/serde_urlencoded) crate)
* [an `Error` enum](#error-handling) that makes it easy to centrally control
  the presentation of all error messages
* cookie-based [CSRF](https://en.wikipedia.org/wiki/Cross-site_request_forgery) tokens
* `Key`: a convenience wrapper around HMAC (stolen from the cookie crate, so
  that you don't have to use `CookieJar`s if you don't need them)
* `decode_expiring_claim` & `encode_expiring_claim`, which can be combined with
  `Key` to implement [signed & expiring cookies](#signed--expiring-cookies)
  (with the expiry date encoded into the signed cookie value)

Sputnik does **not**:

* handle routing: for most web apps `match`ing on (method, path) suffices
* handle configuration: we recommend [toml](https://crates.io/crates/toml)
* handle persistence: we recommend [diesel](https://diesel.rs/)
* handle templating: we recommend [maud](https://maud.lambda.xyz/)

## Error handling

Sputnik defines the following error types:

```rust
pub struct SimpleError {
    pub code: StatusCode,
    pub message: String,
}

pub enum Error {
    Simple(SimpleError),
    Response(hyper::Response<hyper::Body>),
}
```

Sputnik implements `Into<Error::Simple>` for all of its client error types
(e.g. deserialization errors), allowing you to easily customize the error
presentation. Sometimes however a `SimpleError` doesn't suffice, e.g. you
might want to redirect unauthorized users to your login page instead of
showing them an error, for such cases you can return an `Error::Response`.

## CsrfToken example

```rust
use std::convert::Infallible;
use hyper::service::{service_fn, make_service_fn};
use hyper::{Method, Server};
use serde::Deserialize;
use sputnik::security::CsrfToken;
use sputnik::{Error, request::{Parts, Body}, response::Response};

async fn route(req: &mut Parts, body: Body) -> Result<Response,Error> {
    match (req.method(), req.uri().path()) {
        (&Method::GET, "/form") => get_form(req).await,
        (&Method::POST, "/form") => post_form(req, body).await,
        _ => return Err(Error::not_found("page not found".to_owned()))
    }
}

async fn get_form(req: &mut Parts) -> Result<Response, Error> {
    let mut response = Response::new();
    let csrf_token = CsrfToken::from_parts(req, &mut response);
    *response.body() = format!("<form method=post>
        <input name=text>{}<button>Submit</button></form>", csrf_token.html_input()).into();
    Ok(response)
}

#[derive(Deserialize)]
struct FormData {text: String}

async fn post_form(req: &mut Parts, body: Body) -> Result<Response, Error> {
    let mut response = Response::new();
    let csrf_token = CsrfToken::from_parts(req, &mut response);
    let msg: FormData = body.into_form_csrf(&csrf_token).await?;
    *response.body() = format!("hello {}", msg.text).into();
    Ok(response)
}

/// adapt between Hyper's types and Sputnik's convenience types
async fn service(req: hyper::Request<hyper::Body>) -> Result<hyper::Response<hyper::Body>, Infallible> {
    let (mut parts, body) = sputnik::request::adapt(req);
    match route(&mut parts, body).await {
        Ok(res) => Ok(res.into()),
        Err(err) => match err {
            Error::Simple(err) => {
                Ok(err.response_builder().body(err.message.into()).unwrap())
                // you can easily wrap or log errors here
            }
            Error::Response(err) => Ok(err)
        }
    }
}

#[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:

```rust
let expiry_date = OffsetDateTime::now_utc() + Duration::hours(24);
let mut cookie = Cookie::new("userid",
    key.sign(
        &encode_expiring_claim(&userid, expiry_date)
    ));
cookie.set_secure(Some(true));
cookie.set_expires(expiry_date);
cookie.set_same_site(SameSite::Lax);
resp.set_cookie(cookie);
```

This session id cookie can then be retrieved and verified as follows:

```rust
let userid = req.cookies().get("userid")
    .ok_or_else(|| Error::unauthorized("expected userid cookie".to_owned()))
    .and_then(|cookie| key.verify(cookie.value()).map_err(Error::unauthorized))
    .and_then(|value| decode_expiring_claim(value).map_err(|e| Error::unauthorized(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](https://docs.serde.rs/serde_json/).
This approach can pose a lightweight alternative to JWT, if you don't care
about the standardization aspect.