Skip to content

Commit

Permalink
Tide sessions
Browse files Browse the repository at this point in the history
  • Loading branch information
jbr committed Jul 26, 2020
1 parent 724afc9 commit 8f86450
Show file tree
Hide file tree
Showing 8 changed files with 630 additions and 3 deletions.
5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,18 @@ features = ["docs"]
rustdoc-args = ["--cfg", "feature=\"docs\""]

[features]
default = ["h1-server", "logger"]
default = ["h1-server", "logger", "sessions"]
h1-server = ["async-h1"]
logger = []
docs = ["unstable"]
sessions = ["async-session"]
unstable = []
# DO NOT USE. Only exists to expose internals so they can be benchmarked.
__internal__bench = []

[dependencies]
async-h1 = { version = "2.0.1", optional = true }
async-session = { version = "2.0.0", optional = true }
async-sse = "4.0.0"
async-std = { version = "1.6.0", features = ["unstable"] }
async-trait = "0.1.36"
Expand Down Expand Up @@ -65,4 +67,3 @@ required-features = ["unstable"]
[[bench]]
name = "router"
harness = false

39 changes: 39 additions & 0 deletions examples/sessions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#[async_std::main]
async fn main() -> Result<(), std::io::Error> {
tide::log::start();
let mut app = tide::new();

app.middleware(tide::sessions::SessionMiddleware::new(
tide::sessions::MemoryStore::new(),
std::env::var("TIDE_SECRET")
.expect(
"Please provide a TIDE_SECRET value of at \
least 32 bytes in order to run this example",
)
.as_bytes(),
));

app.middleware(tide::utils::Before(
|mut request: tide::Request<()>| async move {
let session = request.session_mut();
let visits: usize = session.get("visits").unwrap_or_default();
session.insert("visits", visits + 1).unwrap();
request
},
));

app.at("/").get(|req: tide::Request<()>| async move {
let visits: usize = req.session().get("visits").unwrap();
Ok(format!("you have visited this website {} times", visits))
});

app.at("/reset")
.get(|mut req: tide::Request<()>| async move {
req.session_mut().destroy();
Ok(tide::Redirect::new("/"))
});

app.listen("127.0.0.1:8080").await?;

Ok(())
}
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@ pub mod security;
pub mod sse;
pub mod utils;

#[cfg(feature = "sessions")]
pub mod sessions;

pub use endpoint::Endpoint;
pub use middleware::{Middleware, Next};
pub use redirect::Redirect;
Expand Down
32 changes: 32 additions & 0 deletions src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,12 @@ impl<State> Request<State> {
self.req.ext().get()
}

/// Get a mutable reference to value stored in request extensions.
#[must_use]
pub fn ext_mut<T: Send + Sync + 'static>(&mut self) -> Option<&mut T> {
self.req.ext_mut().get_mut()
}

/// Set a request extension value.
pub fn set_ext<T: Send + Sync + 'static>(&mut self, val: T) -> Option<T> {
self.req.ext_mut().insert(val)
Expand Down Expand Up @@ -506,6 +512,32 @@ impl<State> Request<State> {
.and_then(|cookie_data| cookie_data.content.read().unwrap().get(name).cloned())
}

/// Retrieves a reference to the current session.
///
/// # Panics
///
/// This method will panic if a tide::sessions:SessionMiddleware has not
/// been run.
#[cfg(feature = "sessions")]
pub fn session(&self) -> &crate::sessions::Session {
self.ext::<crate::sessions::Session>().expect(
"request session not initialized, did you enable tide::sessions::SessionMiddleware?",
)
}

/// Retrieves a mutable reference to the current session.
///
/// # Panics
///
/// This method will panic if a tide::sessions:SessionMiddleware has not
/// been run.
#[cfg(feature = "sessions")]
pub fn session_mut(&mut self) -> &mut crate::sessions::Session {
self.ext_mut().expect(
"request session not initialized, did you enable tide::sessions::SessionMiddleware?",
)
}

/// Get the length of the body stream, if it has been set.
///
/// This value is set when passing a fixed-size object into as the body. E.g. a string, or a
Expand Down
259 changes: 259 additions & 0 deletions src/sessions/middleware.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
use super::{Session, SessionStore};
use crate::http::{
cookies::{Cookie, Key, SameSite},
format_err,
};
use crate::{utils::async_trait, Middleware, Next, Request};
use std::time::Duration;

use async_session::{
base64,
hmac::{Hmac, Mac, NewMac},
sha2::Sha256,
};

const BASE64_DIGEST_LEN: usize = 44;

/// Middleware to enable sessions.
/// ## example:
/// ```rust
/// # async_std::task::block_on(async {
/// let mut app = tide::new();
///
/// app.middleware(tide::sessions::SessionMiddleware::new(
/// tide::sessions::MemoryStore::new(),
/// b"use std::env::var(\"TIDE_SECRET\").unwrap().as_bytes() instead of a fixed value"
/// ));
///
/// app.middleware(tide::utils::Before(|mut request: tide::Request<()>| async move {
/// let session = request.session_mut();
/// let visits: usize = session.get("visits").unwrap_or_default();
/// session.insert("visits", visits + 1).unwrap();
/// request
/// }));
///
/// app.at("/").get(|req: tide::Request<()>| async move {
/// let visits: usize = req.session().get("visits").unwrap();
/// Ok(format!("you have visited this website {} times", visits))
/// });
///
/// app.at("/reset")
/// .get(|mut req: tide::Request<()>| async move {
/// req.session_mut().destroy();
/// Ok(tide::Redirect::new("/"))
/// });
/// # })
/// ```
pub struct SessionMiddleware<Store> {
store: Store,
cookie_path: String,
cookie_name: String,
session_ttl: Option<Duration>,
save_unchanged: bool,
same_site_policy: SameSite,
key: Key,
}

impl<Store: SessionStore> std::fmt::Debug for SessionMiddleware<Store> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionMiddleware")
.field("store", &self.store)
.field("cookie_path", &self.cookie_path)
.field("cookie_name", &self.cookie_name)
.field("session_ttl", &self.session_ttl)
.field("same_site_policy", &self.same_site_policy)
.field("key", &"..")
.field("save_unchanged", &self.save_unchanged)
.finish()
}
}

#[async_trait]
impl<Store, State> Middleware<State> for SessionMiddleware<Store>
where
Store: SessionStore,
State: Clone + Send + Sync + 'static,
{
async fn handle(&self, mut request: Request<State>, next: Next<'_, State>) -> crate::Result {
let cookie = request.cookie(&self.cookie_name);
let cookie_value = cookie
.clone()
.and_then(|cookie| self.verify_signature(cookie.value()).ok());

let mut session = self.load_or_create(cookie_value).await;

if let Some(ttl) = self.session_ttl {
session.expire_in(ttl);
}

let secure_cookie = request.url().scheme() == "https";
request.set_ext(session.clone());

let mut response = next.run(request).await;

if session.is_destroyed() {
if let Err(e) = self.store.destroy_session(session).await {
crate::log::error!("unable to destroy session", { error: e.to_string() });
}

if let Some(mut cookie) = cookie {
cookie.set_path("/");
response.remove_cookie(cookie);
}
} else if self.save_unchanged || session.data_changed() {
if let Some(cookie_value) = self
.store
.store_session(session)
.await
.map_err(|e| format_err!("{}", e.to_string()))?
{
let cookie = self.build_cookie(secure_cookie, cookie_value);
response.insert_cookie(cookie);
}
}

Ok(response)
}
}

impl<Store: SessionStore> SessionMiddleware<Store> {
/// Creates a new SessionMiddleware with a mandatory cookie
/// signing secret. The `secret` MUST be at least 32 bytes long,
/// and should be cryptographically random. It is recommended to
/// retrieve this at runtime from the environment instead of
/// compiling it into your application. SessionMiddleware::new
/// will panic if the secret is fewer than 32 bytes.
///
/// The defaults for SessionMiddleware are:
/// * cookie path: "/"
/// * cookie name: "tide.sid"
/// * session ttl: one day
/// * same site: strict
/// * save unchanged: enabled
pub fn new(store: Store, secret: &[u8]) -> Self {
Self {
store,
save_unchanged: true,
cookie_path: "/".into(),
cookie_name: "tide.sid".into(),
same_site_policy: SameSite::Strict,
session_ttl: Some(Duration::from_secs(24 * 60 * 60)),
key: Key::derive_from(secret),
}
}

/// Sets a cookie path for this session middleware.
/// The default for this value is "/"
pub fn with_cookie_path(mut self, cookie_path: impl AsRef<str>) -> Self {
self.cookie_path = cookie_path.as_ref().to_owned();
self
}

/// Sets a session ttl. This will be used both for the cookie
/// expiry and also for the session-internal expiry.
///
/// The default for this value is one day. Set this to None to not
/// set a cookie or session expiry. This is not recommended.
pub fn with_session_ttl(mut self, session_ttl: Option<Duration>) -> Self {
self.session_ttl = session_ttl;
self
}

/// Sets the name of the cookie that the session is stored with or in.
///
/// If you are running multiple tide applications on the same
/// domain, you will need different values for each
/// application. The default value is "tide.sid"
pub fn with_cookie_name(mut self, cookie_name: impl AsRef<str>) -> Self {
self.cookie_name = cookie_name.as_ref().to_owned();
self
}

/// Disables the `save_unchanged` setting. When `save_unchanged`
/// is enabled, a session will cookie will always be set. With
/// `save_unchanged` disabled, the session data must be modified
/// from the `Default` value in order for it to save. If a session
/// already exists and its data unmodified in the course of a
/// request, the session will only be persisted if
/// `save_unchanged` is enabled.
pub fn without_save_unchanged(mut self) -> Self {
self.save_unchanged = false;
self
}

/// Sets the same site policy for the session cookie. Defaults to
/// SameSite::Strict. See [incrementally better
/// cookies](https://tools.ietf.org/html/draft-west-cookie-incrementalism-01)
/// for more information about this setting
pub fn with_same_site_policy(mut self, policy: SameSite) -> Self {
self.same_site_policy = policy;
self
}

//--- methods below here are private ---

async fn load_or_create(&self, cookie_value: Option<String>) -> Session {
let session = match cookie_value {
Some(cookie_value) => self.store.load_session(cookie_value).await.ok().flatten(),
None => None,
};

session
.and_then(|session| session.validate())
.unwrap_or_default()
}

fn build_cookie(&self, secure: bool, cookie_value: String) -> Cookie<'static> {
let mut cookie = Cookie::build(self.cookie_name.clone(), cookie_value)
.http_only(true)
.same_site(self.same_site_policy)
.secure(secure)
.path(self.cookie_path.clone())
.finish();

if let Some(ttl) = self.session_ttl {
cookie.set_expires(Some((std::time::SystemTime::now() + ttl).into()));
}

self.sign_cookie(&mut cookie);

cookie
}

// the following is reused verbatim from
// https://github.com/SergioBenitez/cookie-rs/blob/master/src/secure/signed.rs#L33-L43
/// Signs the cookie's value providing integrity and authenticity.
fn sign_cookie(&self, cookie: &mut Cookie<'_>) {
// Compute HMAC-SHA256 of the cookie's value.
let mut mac = Hmac::<Sha256>::new_varkey(&self.key.signing()).expect("good key");
mac.update(cookie.value().as_bytes());

// Cookie's new value is [MAC | original-value].
let mut new_value = base64::encode(&mac.finalize().into_bytes());
new_value.push_str(cookie.value());
cookie.set_value(new_value);
}

// the following is reused verbatim from
// https://github.com/SergioBenitez/cookie-rs/blob/master/src/secure/signed.rs#L45-L63
/// Given a signed value `str` where the signature is prepended to `value`,
/// verifies the signed value and returns it. If there's a problem, returns
/// an `Err` with a string describing the issue.
fn verify_signature(&self, cookie_value: &str) -> Result<String, &'static str> {
if cookie_value.len() < BASE64_DIGEST_LEN {
return Err("length of value is <= BASE64_DIGEST_LEN");
}

// Split [MAC | original-value] into its two parts.
let (digest_str, value) = cookie_value.split_at(BASE64_DIGEST_LEN);
let digest = base64::decode(digest_str).map_err(|_| "bad base64 digest")?;

// Perform the verification.
let mut mac = Hmac::<Sha256>::new_varkey(&self.key.signing()).expect("good key");
mac.update(value.as_bytes());
mac.verify(&digest)
.map(|_| value.to_string())
.map_err(|_| "value did not verify")
}
}
Loading

0 comments on commit 8f86450

Please sign in to comment.