aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.toml2
-rw-r--r--README.md19
-rw-r--r--src/request.rs82
-rw-r--r--src/response.rs82
-rw-r--r--src/security.rs17
5 files changed, 134 insertions, 68 deletions
diff --git a/Cargo.toml b/Cargo.toml
index e1442c9..22cac22 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,12 +18,10 @@ security = ["base64", "hmac", "rand", "sha2"]
[dependencies]
http = "0.2"
-cookie = { version = "0.15", features = ["percent-encode"] }
serde = { version = "1.0", features = ["derive"] }
serde_urlencoded = "0.7.0"
httpdate = "0.3.2"
mime = "0.3"
-time = "0.2"
thiserror = "1.0"
hyper = { version = "0.14", optional = true }
diff --git a/README.md b/README.md
index c36d39b..30ef9c5 100644
--- a/README.md
+++ b/README.md
@@ -127,23 +127,28 @@ 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 expiry_date = SystemTime::now() + Duration::from_secs(24 * 60 * 60);
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);
-headers.set_cookie(cookie);
+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:
```rust
-let userid = req.cookies().get("userid")
+let userid = req.cookies().find(|(name, _value)| *name == "userid")
.ok_or_else(|| "expected userid cookie".to_owned())
- .and_then(|cookie| key.verify(cookie.value())
+ .and_then(|(_name, value)| key.verify(value))
.and_then(|value| decode_expiring_claim(value).map_err(|e| format!("failed to decode userid cookie: {}", e)));
```
diff --git a/src/request.rs b/src/request.rs
index 6efacc9..4f45e04 100644
--- a/src/request.rs
+++ b/src/request.rs
@@ -1,12 +1,11 @@
//! Provides the [`SputnikParts`] trait.
-use cookie::Cookie;
use mime::Mime;
use serde::de::DeserializeOwned;
-use time::Duration;
-use std::{collections::HashMap, sync::Arc};
+use std::str::Split;
+use std::time::Duration;
-use crate::response::{SputnikHeaders, delete_cookie};
+use crate::response::{Cookie, SputnikHeaders, delete_cookie};
use crate::http::{HeaderMap, header, request::Parts};
/// Adds convenience methods to [`http::request::Parts`](Parts).
@@ -15,7 +14,7 @@ pub trait SputnikParts {
fn query<X: DeserializeOwned>(&self) -> Result<X,QueryError>;
/// Parses the cookies of the request.
- fn cookies(&mut self) -> Arc<HashMap<String, Cookie<'static>>>;
+ fn cookies(&self) -> CookieIter;
/// Enforces a specific Content-Type.
fn enforce_content_type(&self, mime: Mime) -> Result<(), WrongContentTypeError>;
@@ -29,6 +28,29 @@ pub trait SputnikParts {
fn response_headers(&mut self) -> &mut HeaderMap;
}
+pub struct CookieIter<'a>(Split<'a, char>);
+
+impl<'a> Iterator for CookieIter<'a> {
+ type Item = (&'a str, &'a str);
+
+ fn next(&mut self) -> Option<Self::Item> {
+ self.0.next().and_then(|str| {
+ let mut iter = str.splitn(2, '=');
+ let name = iter.next().expect("first splitn().next() returns Some");
+ let value = iter.next();
+ match value {
+ None => self.next(),
+ Some(mut value) => {
+ if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
+ value = &value[1..value.len()-1];
+ }
+ Some((name, value))
+ }
+ }
+ })
+ }
+}
+
impl SputnikParts for Parts {
fn query<T: DeserializeOwned>(&self) -> Result<T,QueryError> {
serde_urlencoded::from_str::<T>(self.uri.query().unwrap_or("")).map_err(QueryError)
@@ -41,28 +63,8 @@ impl SputnikParts for Parts {
self.extensions.get_mut::<HeaderMap>().unwrap()
}
- fn cookies(&mut self) -> Arc<HashMap<String, Cookie<'static>>> {
- let cookies: Option<&Arc<HashMap<String, Cookie>>> = self.extensions.get();
- if let Some(cookies) = cookies {
- return cookies.clone();
- }
-
- let mut cookies = HashMap::new();
- for header in self.headers.get_all(header::COOKIE) {
- let raw_str = match std::str::from_utf8(header.as_bytes()) {
- Ok(string) => string,
- Err(_) => continue
- };
-
- for cookie_str in raw_str.split(';').map(|s| s.trim()) {
- if let Ok(cookie) = Cookie::parse_encoded(cookie_str) {
- cookies.insert(cookie.name().to_string(), cookie.into_owned());
- }
- }
- }
- let cookies = Arc::new(cookies);
- self.extensions.insert(cookies.clone());
- cookies
+ fn cookies(&self) -> CookieIter {
+ CookieIter(self.headers.get(header::COOKIE).and_then(|h| std::str::from_utf8(h.as_bytes()).ok()).unwrap_or("").split(';'))
}
fn enforce_content_type(&self, mime: Mime) -> Result<(), WrongContentTypeError> {
@@ -83,10 +85,14 @@ pub struct Flash {
message: String,
}
-impl From<Flash> for Cookie<'_> {
+impl From<Flash> for Cookie {
fn from(flash: Flash) -> Self {
- Cookie::build(FLASH_COOKIE_NAME, format!("{}:{}", flash.name, flash.message))
- .max_age(Duration::minutes(5)).finish()
+ Cookie {
+ name: FLASH_COOKIE_NAME.into(),
+ value: format!("{}:{}", flash.name, flash.message),
+ max_age: Some(Duration::from_secs(5 * 60)),
+ ..Default::default()
+ }
}
}
@@ -94,15 +100,13 @@ impl Flash {
/// If the request has a flash cookie retrieve it and append a set-cookie
/// header to delete the cookie to [`SputnikParts::response_headers`].
pub fn from_request(req: &mut Parts) -> Option<Self> {
- req.cookies().get(FLASH_COOKIE_NAME)
- .and_then(|cookie| {
- req.response_headers().set_cookie(delete_cookie(FLASH_COOKIE_NAME));
- let mut iter = cookie.value().splitn(2, ':');
- if let (Some(name), Some(message)) = (iter.next(), iter.next()) {
- return Some(Flash{name: name.to_owned(), message: message.to_owned()})
- }
- None
- })
+ let value = req.cookies().find(|(name, _value)| *name == FLASH_COOKIE_NAME)?.1.to_owned();
+ req.response_headers().set_cookie(delete_cookie(FLASH_COOKIE_NAME));
+ let mut iter = value.splitn(2, ':');
+ if let (Some(name), Some(message)) = (iter.next(), iter.next()) {
+ return Some(Flash{name: name.to_owned(), message: message.to_owned()})
+ }
+ None
}
/// Constructs a new Flash message. The name must not contain a colon (`:`).
diff --git a/src/response.rs b/src/response.rs
index cb87a80..c9d83a7 100644
--- a/src/response.rs
+++ b/src/response.rs
@@ -1,9 +1,6 @@
//! Provides convenience traits and functions to build HTTP responses.
-use std::convert::TryInto;
-
-use cookie::Cookie;
-use time::{Duration, OffsetDateTime};
+use std::{convert::TryInto, fmt::Display, time::{Duration, SystemTime}};
use crate::http::{self, HeaderMap, StatusCode, header, response::Builder};
@@ -16,6 +13,67 @@ pub trait SputnikBuilder {
fn set_cookie(self, cookie: Cookie) -> Builder;
}
+#[derive(Default, Debug)]
+pub struct Cookie {
+ pub name: String,
+ pub value: String,
+ pub expires: Option<SystemTime>,
+ pub max_age: Option<Duration>,
+ pub domain: Option<String>,
+ pub path: Option<String>,
+ pub secure: Option<bool>,
+ pub http_only: Option<bool>,
+ pub same_site: Option<SameSite>,
+}
+
+impl Display for Cookie {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}={}", self.name, self.value)?;
+ if let Some(true) = self.http_only {
+ write!(f, "; HttpOnly")?;
+ }
+ if let Some(same_site) = &self.same_site {
+ write!(f, "; SameSite={}", same_site)?;
+
+ if same_site == &SameSite::None && self.secure.is_none() {
+ write!(f, "; Secure")?;
+ }
+ }
+ if let Some(true) = self.secure {
+ write!(f, "; Secure")?;
+ }
+ if let Some(path) = &self.path {
+ write!(f, "; Path={}", path)?;
+ }
+ if let Some(domain) = &self.domain {
+ write!(f, "; Domain={}", domain)?;
+ }
+ if let Some(max_age) = &self.max_age {
+ write!(f, "; Max-Age={}", max_age.as_secs())?;
+ }
+ if let Some(time) = self.expires {
+ write!(f, "; Expires={}", httpdate::fmt_http_date(time))?;
+ }
+
+ Ok(())
+ }
+}
+
+#[derive(Debug, PartialEq)]
+pub enum SameSite {
+ Strict, Lax, None
+}
+
+impl Display for SameSite {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ SameSite::Strict => write!(f, "Strict"),
+ SameSite::Lax => write!(f, "Lax"),
+ SameSite::None => write!(f, "None"),
+ }
+ }
+}
+
/// Creates a new builder with a given Location header and status code.
pub fn redirect(location: &str, code: StatusCode) -> Builder {
Builder::new().status(code).header(header::LOCATION, location)
@@ -35,10 +93,12 @@ impl SputnikBuilder for Builder {
/// Constructs an expired cookie to delete a cookie.
pub fn delete_cookie(name: &str) -> Cookie {
- let mut cookie = Cookie::new(name, "");
- cookie.set_max_age(Duration::seconds(0));
- cookie.set_expires(OffsetDateTime::now_utc() - Duration::days(365));
- cookie
+ Cookie{
+ name: name.into(),
+ max_age: Some(Duration::from_secs(0)),
+ expires: Some(SystemTime::now() - Duration::from_secs(60*60*24)),
+ ..Default::default()
+ }
}
/// Adds convenience methods to [`HeaderMap`].
@@ -56,7 +116,7 @@ impl SputnikHeaders for HeaderMap {
}
fn set_cookie(&mut self, cookie: Cookie) {
- self.append(header::SET_COOKIE, cookie.encoded().to_string().try_into().unwrap());
+ self.append(header::SET_COOKIE, cookie.to_string().try_into().unwrap());
}
}
@@ -73,8 +133,8 @@ mod tests {
#[test]
fn test_set_cookie() {
let mut map = HeaderMap::new();
- map.set_cookie(Cookie::new("some", "cookie"));
- map.set_cookie(Cookie::new("some", "cookie"));
+ map.set_cookie(Cookie{name: "some".into(), value: "cookie".into(), ..Default::default()});
+ map.set_cookie(Cookie{name: "some".into(), value: "cookie".into(), ..Default::default()});
assert_eq!(map.len(), 2);
}
diff --git a/src/security.rs b/src/security.rs
index abe114e..cd9d7bd 100644
--- a/src/security.rs
+++ b/src/security.rs
@@ -1,14 +1,13 @@
//! Provides [`Key`] and functions to encode & decode expiring claims.
-use time::OffsetDateTime;
-
pub use signed::Key;
+pub use std::time::{SystemTime, UNIX_EPOCH};
mod signed;
/// Join a string and an expiry date together into a string.
-pub fn encode_expiring_claim(claim: &str, expiry_date: OffsetDateTime) -> String {
- format!("{}:{}", claim, expiry_date.unix_timestamp())
+pub fn encode_expiring_claim(claim: &str, expiry_date: SystemTime) -> String {
+ format!("{}:{}", claim, expiry_date.duration_since(UNIX_EPOCH).unwrap().as_secs())
}
/// Extract the string, failing if the expiry date is in the past.
@@ -16,9 +15,9 @@ pub fn decode_expiring_claim(value: &str) -> Result<&str, &'static str> {
let mut parts = value.rsplitn(2, ':');
let expiry_date = parts.next().expect("first .rsplitn().next() is expected to return Some");
let claim = parts.next().ok_or("expected colon")?;
- let expiry_date: i64 = expiry_date.parse().map_err(|_| "failed to parse timestamp")?;
+ let expiry_date: u64 = expiry_date.parse().map_err(|_| "failed to parse timestamp")?;
- if expiry_date > OffsetDateTime::now_utc().unix_timestamp() {
+ if expiry_date > SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() {
Ok(claim)
} else {
Err("token is expired")
@@ -27,15 +26,15 @@ pub fn decode_expiring_claim(value: &str) -> Result<&str, &'static str> {
#[cfg(test)]
mod tests {
- use time::{OffsetDateTime, Duration};
+ use std::time::{SystemTime, Duration};
#[test]
fn test_expiring_claim() {
for claim in vec!["test", "", "foo:bar"] {
- let encoded_claim = super::encode_expiring_claim(claim, OffsetDateTime::now_utc() + Duration::minutes(1));
+ let encoded_claim = super::encode_expiring_claim(claim, SystemTime::now() + Duration::from_secs(60));
assert_eq!(super::decode_expiring_claim(&encoded_claim).unwrap(), claim);
- let encoded_claim = super::encode_expiring_claim(claim, OffsetDateTime::now_utc() - Duration::minutes(1));
+ let encoded_claim = super::encode_expiring_claim(claim, SystemTime::now() - Duration::from_secs(60));
assert!(super::decode_expiring_claim(&encoded_claim).is_err());
}
assert!(super::decode_expiring_claim("test".into()).is_err());