Compare commits

...

10 Commits

Author SHA1 Message Date
96ba2d5d74 Fix compile error.
- Cookie::build(self.config.cookie_name.clone(), session.to_string())
+ Cookie::build((self.config.cookie_name.clone(), session.to_string()))

- async fn from_request(request: &'r Request<'_>) -> Outcome<Self, (Status, Self::Error), ()> {
+ async fn from_request(request: &'r Request<'_>) -> Outcome<Self, (Status, Self::Error), Status> {
2024-12-17 01:12:26 +08:00
Ondřej Hruška
82f9519f95 changelog 2023-01-10 10:10:20 +01:00
Ondřej Hruška
f30b1f5be1 Merge branch 'weissjeffm-rocket-0.5' 2023-01-10 10:09:17 +01:00
Ondřej Hruška
b0a2d8c910 Add minimal example, some formatting, bump parking_lot version 2023-01-10 10:08:44 +01:00
Jeff Weiss
bc2180d1e4 Now compiles against rocket-0.5.0-rc.2 2023-01-09 08:50:45 -05:00
Ondřej Hruška
c5a9767881 update deps, new version 2021-01-24 11:30:28 +01:00
Ondřej Hruška
89bebc6b03
change rng to OsRng, version bump 2020-02-09 21:51:16 +01:00
Ondřej Hruška
4046e7f185
clean up the dogs example 2019-12-31 20:56:28 +01:00
Ondřej Hruška
f8d5445cdc
version bump, comments, improve examples, update readme 2019-12-31 20:48:56 +01:00
Ondřej Hruška
cde08fe788
add examples, automatic expired removal, better configurability 2019-12-31 20:31:38 +01:00
7 changed files with 463 additions and 141 deletions

13
CHANGELOG.md Normal file
View File

@ -0,0 +1,13 @@
# [0.3.0]
- Update dependencies
- Added new example
- Port to rocket `0.5.0-rc.2`
# [0.2.2]
- Update dependencies
# [0.2.1]
- change from `thread_rng` to `OsRng` for better session ID entropy

View File

@ -1,8 +1,8 @@
[package]
name = "rocket_session"
version = "0.1.1"
version = "0.3.0"
authors = ["Ondřej Hruška <ondra@ondrovo.com>"]
edition = "2018"
edition = "2021"
license = "MIT"
description = "Rocket.rs plug-in for cookie-based sessions holding arbitrary data"
repository = "https://git.ondrovo.com/packages/rocket_session"
@ -16,6 +16,6 @@ categories = [
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rand = "0.7.2"
rocket = "0.4.2"
parking_lot = "0.10.0"
rand = "0.8"
rocket = "0.5.0-rc.2"
parking_lot = "0.12"

View File

@ -2,35 +2,55 @@
Adding cookie-based sessions to a rocket application is extremely simple with this crate.
Sessions are used to share data between related requests, such as user authentication, shopping basket,
form values that failed validation for re-filling, etc.
## Configuration
The implementation is generic to support any type as session data: a custom struct, `String`,
`HashMap`, or perhaps `serde_json::Value`. You're free to choose.
The session expiry time is configurable through the Fairing. When a session expires,
the data associated with it is dropped. All expired sessions may be cleared by calling `.remove_expired()`
on the `SessionStore`, which is be obtained in routes as `State<SessionStore>`, or from a
session instance by calling `.get_store()`.
The session lifetime, cookie name, and other parameters can be configured by calling chained
methods on the fairing. When a session expires, the data associated with it is dropped.
The session cookie is currently hardcoded to "SESSID" and contains 16 random characters.
Example: `Session::fairing().with_lifetime(Duration::from_secs(15))`
## Basic Example
## Usage
To use session in a route, first make sure you have the fairing attached by calling
`rocket.attach(Session::fairing())` at start-up, and then add something like `session : Session`
to the parameter list of your route(s). Everything else--session init, expiration, cookie
management--is done for you behind the scenes.
Session data is accessed in a closure run in the session context, using the `session.tap()`
method. This closure runs inside a per-session mutex, avoiding simultaneous mutation
from different requests. Try to *avoid lengthy operations inside the closure*,
as it effectively blocks any other request to session-enabled routes by the client.
Every request to a session-enabled route extends the session's lifetime to the full
configured time (defaults to 1 hour). Automatic clean-up removes expired sessions to make sure
the session list does not waste memory.
## Examples
More examples are in the "examples" folder - run with `cargo run --example=NAME`
### Basic Example
This simple example uses u64 as the session variable; note that it can be a struct, map, or anything else,
it just needs to implement `Send + Sync + Default`.
```rust
#![feature(proc_macro_hygiene, decl_macro)]
#[macro_use] extern crate rocket;
#[macro_use]
extern crate rocket;
use std::time::Duration;
type Session<'a> = rocket_session::Session<'a, u64>;
// It's convenient to define a type alias:
pub type Session<'a> = rocket_session::Session<'a, u64>;
fn main() {
rocket::ignite()
.attach(Session::fairing(Duration::from_secs(3600)))
#[launch]
fn rocket() -> _ {
rocket::build()
.attach(Session::fairing())
.mount("/", routes![index])
.launch();
}
#[get("/")]
@ -46,6 +66,7 @@ fn index(session: Session) -> String {
format!("{} visits", count)
}
```
## Extending Session by a Trait
@ -53,7 +74,10 @@ fn index(session: Session) -> String {
The `.tap()` method is powerful, but sometimes you may wish for something more convenient.
Here is an example of using a custom trait and the `json_dotpath` crate to implement
a polymorphic store based on serde serialization:
a polymorphic store based on serde serialization.
Note that this approach is prone to data races if you're accessing the session object multiple times per request,
since every method contains its own `.tap()`. It may be safer to simply call the `.dot_*()` methods manually in one shared closure.
```rust
use serde_json::Value;

59
examples/dog_list/main.rs Normal file
View File

@ -0,0 +1,59 @@
#[macro_use]
extern crate rocket;
use rocket::response::content::RawHtml;
use rocket::response::Redirect;
type Session<'a> = rocket_session::Session<'a, Vec<String>>;
#[launch]
fn rocket() -> _ {
rocket::build()
.attach(Session::fairing())
.mount("/", routes![index, add, remove])
}
#[get("/")]
fn index(session: Session) -> RawHtml<String> {
let mut page = String::new();
page.push_str(
r#"
<!DOCTYPE html>
<h1>My Dogs</h1>
<form method="POST" action="/add">
Add Dog: <input type="text" name="name"> <input type="submit" value="Add">
</form>
<ul>
"#,
);
session.tap(|sess| {
for (n, dog) in sess.iter().enumerate() {
page.push_str(&format!(
r#"<li>&#x1F436; {} <a href="/remove/{}">Remove</a></li>"#,
dog, n
));
}
});
page.push_str("</ul>");
RawHtml(page)
}
#[post("/add", data = "<dog>")]
fn add(session: Session, dog: String) -> Redirect {
session.tap(move |sess| {
sess.push(dog);
});
Redirect::found("/")
}
#[get("/remove/<dog>")]
fn remove(session: Session, dog: usize) -> Redirect {
session.tap(|sess| {
if dog < sess.len() {
sess.remove(dog);
}
});
Redirect::found("/")
}

28
examples/minimal/main.rs Normal file
View File

@ -0,0 +1,28 @@
#[macro_use]
extern crate rocket;
use std::time::Duration;
type Session<'a> = rocket_session::Session<'a, u64>;
#[launch]
fn rocket() -> _ {
// This session expires in 15 seconds as a demonstration of session configuration
rocket::build()
.attach(Session::fairing().with_lifetime(Duration::from_secs(15)))
.mount("/", routes![index])
}
#[get("/")]
fn index(session: Session) -> String {
let count = session.tap(|n| {
// Change the stored value (it is &mut)
*n += 1;
// Return something to the caller.
// This can be any type, 'tap' is generic.
*n
});
format!("{} visits", count)
}

View File

@ -0,0 +1,73 @@
//! This demo is a page visit counter, with a custom cookie name, length, and expiry time.
//!
//! The expiry time is set to 10 seconds to illustrate how a session is cleared if inactive.
#[macro_use]
extern crate rocket;
use rocket::response::content::RawHtml;
use std::time::Duration;
#[derive(Default, Clone)]
struct SessionData {
visits1: usize,
visits2: usize,
}
// It's convenient to define a type alias:
type Session<'a> = rocket_session::Session<'a, SessionData>;
#[launch]
fn rocket() -> _ {
rocket::build()
.attach(
Session::fairing()
// 10 seconds of inactivity until session expires
// (wait 10s and refresh, the numbers will reset)
.with_lifetime(Duration::from_secs(10))
// custom cookie name and length
.with_cookie_name("my_cookie")
.with_cookie_len(20),
)
.mount("/", routes![index, about])
}
#[get("/")]
fn index(session: Session) -> RawHtml<String> {
// Here we build the entire response inside the 'tap' closure.
// While inside, the session is locked to parallel changes, e.g.
// from a different browser tab.
session.tap(|sess| {
sess.visits1 += 1;
RawHtml(format!(
r##"
<!DOCTYPE html>
<h1>Home</h1>
<a href="/">Refresh</a> &bull; <a href="/about/">go to About</a>
<p>Visits: home {}, about {}</p>
"##,
sess.visits1, sess.visits2
))
})
}
#[get("/about")]
fn about(session: Session) -> RawHtml<String> {
// Here we return a value from the tap function and use it below
let count = session.tap(|sess| {
sess.visits2 += 1;
sess.visits2
});
RawHtml(format!(
r##"
<!DOCTYPE html>
<h1>About</h1>
<a href="/about">Refresh</a> &bull; <a href="/">go home</a>
<p>Page visits: {}</p>
"##,
count
))
}

View File

@ -1,26 +1,84 @@
use parking_lot::RwLock;
use rand::Rng;
use rocket::{
fairing::{self, Fairing, Info},
http::{Cookie, Status},
request::FromRequest,
Outcome, Request, Response, Rocket, State,
};
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt::{self, Display, Formatter};
use std::marker::PhantomData;
use std::ops::Add;
use std::time::{Duration, Instant};
const SESSION_COOKIE: &str = "SESSID";
const SESSION_ID_LEN: usize = 16;
use parking_lot::{Mutex, RwLock, RwLockUpgradableReadGuard};
use rand::{rngs::OsRng, Rng};
use rocket::{
fairing::{self, Fairing, Info},
http::{Cookie, Status},
outcome::Outcome,
request::FromRequest,
Build, Request, Response, Rocket, State,
};
/// Session store (shared state)
#[derive(Debug)]
pub struct SessionStore<D>
where
D: 'static + Sync + Send + Default,
{
/// The internally mutable map of sessions
inner: RwLock<StoreInner<D>>,
// Session config
config: SessionConfig,
}
/// Session config object
#[derive(Debug, Clone)]
struct SessionConfig {
/// Sessions lifespan
lifespan: Duration,
/// Session cookie name
cookie_name: Cow<'static, str>,
/// Session cookie path
cookie_path: Cow<'static, str>,
/// Session ID character length
cookie_len: usize,
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
lifespan: Duration::from_secs(3600),
cookie_name: "rocket_session".into(),
cookie_path: "/".into(),
cookie_len: 16,
}
}
}
/// Mutable object stored inside SessionStore behind a RwLock
#[derive(Debug)]
struct StoreInner<D>
where
D: 'static + Sync + Send + Default,
{
sessions: HashMap<String, Mutex<SessionInstance<D>>>,
last_expiry_sweep: Instant,
}
impl<D> Default for StoreInner<D>
where
D: 'static + Sync + Send + Default,
{
fn default() -> Self {
Self {
sessions: Default::default(),
// the first expiry sweep is scheduled one lifetime from start-up
last_expiry_sweep: Instant::now(),
}
}
}
/// Session, as stored in the sessions store
#[derive(Debug)]
struct SessionInstance<D>
where
D: 'static + Sync + Send + Default,
where
D: 'static + Sync + Send + Default,
{
/// Data object
data: D,
@ -28,101 +86,117 @@ struct SessionInstance<D>
expires: Instant,
}
/// Session store (shared state)
#[derive(Default, Debug)]
pub struct SessionStore<D>
where
D: 'static + Sync + Send + Default,
{
/// The internaly mutable map of sessions
inner: RwLock<HashMap<String, SessionInstance<D>>>,
/// Sessions lifespan
lifespan: Duration,
}
impl<D> SessionStore<D>
where
D: 'static + Sync + Send + Default,
{
/// Remove all expired sessions
pub fn remove_expired(&self) {
let now = Instant::now();
self.inner.write().retain(|_k, v| v.expires > now);
}
}
/// Session ID newtype for rocket's "local_cache"
#[derive(PartialEq, Hash, Clone, Debug)]
#[derive(Clone, Debug)]
struct SessionID(String);
impl SessionID {
fn as_str(&self) -> &str {
self.0.as_str()
}
}
fn to_string(&self) -> String {
self.0.clone()
impl Display for SessionID {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// Session instance
///
/// To access the active session, simply add it as an argument to a route function.
///
/// Sessions are started, restored, or expired in the `FromRequest::from_request()` method
/// when a `Session` is prepared for one of the route functions.
#[derive(Debug)]
pub struct Session<'a, D>
where
D: 'static + Sync + Send + Default,
where
D: 'static + Sync + Send + Default,
{
/// The shared state reference
store: State<'a, SessionStore<D>>,
store: &'a State<SessionStore<D>>,
/// Session ID
id: &'a SessionID,
}
impl<'a, 'r, D> FromRequest<'a, 'r> for Session<'a, D>
where
D: 'static + Sync + Send + Default,
#[rocket::async_trait]
impl<'r, D> FromRequest<'r> for Session<'r, D>
where
D: 'static + Sync + Send + Default,
{
type Error = ();
fn from_request(request: &'a Request<'r>) -> Outcome<Self, (Status, Self::Error), ()> {
let store : State<SessionStore<D>> = request.guard().unwrap();
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, (Status, Self::Error), Status> {
let store = request.guard::<&State<SessionStore<D>>>().await.unwrap();
Outcome::Success(Session {
id: request.local_cache(|| {
let store_ug = store.inner.upgradable_read();
// Resolve session ID
let id = if let Some(cookie) = request.cookies().get(SESSION_COOKIE) {
SessionID(cookie.value().to_string())
} else {
SessionID(
rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(SESSION_ID_LEN)
.collect(),
)
};
let id = request
.cookies()
.get(&store.config.cookie_name)
.map(|cookie| SessionID(cookie.value().to_string()));
let new_expiration = Instant::now().add(store.lifespan);
let mut wg = store.inner.write();
match wg.get_mut(id.as_str()) {
Some(ses) => {
// Check expiration
if ses.expires <= Instant::now() {
ses.data = D::default();
}
// Update expiry timestamp
ses.expires = new_expiration;
},
None => {
// New session
wg.insert(
id.to_string(),
SessionInstance {
data: D::default(),
expires: new_expiration,
}
);
let expires = Instant::now().add(store.config.lifespan);
if let Some(m) = id
.as_ref()
.and_then(|token| store_ug.sessions.get(token.as_str()))
{
// --- ID obtained from a cookie && session found in the store ---
let mut inner = m.lock();
if inner.expires <= Instant::now() {
// Session expired, reuse the ID but drop data.
inner.data = D::default();
}
};
id
// Session is extended by making a request with valid ID
inner.expires = expires;
id.unwrap()
} else {
// --- ID missing or session not found ---
// Get exclusive write access to the map
let mut store_wg = RwLockUpgradableReadGuard::upgrade(store_ug);
// This branch runs less often, and we already have write access,
// let's check if any sessions expired. We don't want to hog memory
// forever by abandoned sessions (e.g. when a client lost their cookie)
// Throttle by lifespan - e.g. sweep every hour
if store_wg.last_expiry_sweep.elapsed() > store.config.lifespan {
let now = Instant::now();
store_wg.sessions.retain(|_k, v| v.lock().expires > now);
store_wg.last_expiry_sweep = now;
}
// Find a new unique ID - we are still safely inside the write guard
let new_id = SessionID(loop {
let token: String = OsRng
.sample_iter(&rand::distributions::Alphanumeric)
.take(store.config.cookie_len)
.map(char::from)
.collect();
if !store_wg.sessions.contains_key(&token) {
break token;
}
});
store_wg.sessions.insert(
new_id.to_string(),
Mutex::new(SessionInstance {
data: Default::default(),
expires,
}),
);
new_id
}
}),
store,
})
@ -130,76 +204,127 @@ impl<'a, 'r, D> FromRequest<'a, 'r> for Session<'a, D>
}
impl<'a, D> Session<'a, D>
where
D: 'static + Sync + Send + Default,
where
D: 'static + Sync + Send + Default,
{
/// Get the fairing object
pub fn fairing(lifespan: Duration) -> impl Fairing {
SessionFairing::<D> {
lifespan,
_phantom: PhantomData,
}
/// Create the session fairing.
///
/// You can configure the session store by calling chained methods on the returned value
/// before passing it to `rocket.attach()`
pub fn fairing() -> SessionFairing<D> {
SessionFairing::<D>::new()
}
/// Access the session store
pub fn get_store(&self) -> &SessionStore<D> {
&self.store
}
/// Set the session object to its default state
pub fn reset(&self) {
self.tap_mut(|m| {
/// Clear session data (replace the value with default)
pub fn clear(&self) {
self.tap(|m| {
*m = D::default();
})
}
pub fn tap<T>(&self, func: impl FnOnce(&D) -> T) -> T {
let rg = self.store.inner.read();
let instance = rg.get(self.id.as_str()).unwrap();
func(&instance.data)
}
/// Access the session's data using a closure.
///
/// The closure is called with the data value as a mutable argument,
/// and can return any value to be is passed up to the caller.
pub fn tap<T>(&self, func: impl FnOnce(&mut D) -> T) -> T {
// Use a read guard, so other already active sessions are not blocked
// from accessing the store. New incoming clients may be blocked until
// the tap() call finishes
let store_rg = self.store.inner.read();
// Unlock the session's mutex.
// Expiry was checked and prolonged at the beginning of the request
let mut instance = store_rg
.sessions
.get(self.id.as_str())
.expect("Session data unexpectedly missing")
.lock();
pub fn tap_mut<T>(&self, func: impl FnOnce(&mut D) -> T) -> T {
let mut wg = self.store.inner.write();
let instance = wg.get_mut(self.id.as_str()).unwrap();
func(&mut instance.data)
}
}
/// Fairing struct
struct SessionFairing<D>
where
D: 'static + Sync + Send + Default,
#[derive(Default)]
pub struct SessionFairing<D>
where
D: 'static + Sync + Send + Default,
{
lifespan: Duration,
_phantom: PhantomData<D>,
config: SessionConfig,
phantom: PhantomData<D>,
}
impl<D> SessionFairing<D>
where
D: 'static + Sync + Send + Default,
{
fn new() -> Self {
Self::default()
}
/// Set session lifetime (expiration time).
///
/// Call on the fairing before passing it to `rocket.attach()`
pub fn with_lifetime(mut self, time: Duration) -> Self {
self.config.lifespan = time;
self
}
/// Set session cookie name and length
///
/// Call on the fairing before passing it to `rocket.attach()`
pub fn with_cookie_name(mut self, name: impl Into<Cow<'static, str>>) -> Self {
self.config.cookie_name = name.into();
self
}
/// Set session cookie name and length
///
/// Call on the fairing before passing it to `rocket.attach()`
pub fn with_cookie_len(mut self, length: usize) -> Self {
self.config.cookie_len = length;
self
}
/// Set session cookie name and length
///
/// Call on the fairing before passing it to `rocket.attach()`
pub fn with_cookie_path(mut self, path: impl Into<Cow<'static, str>>) -> Self {
self.config.cookie_path = path.into();
self
}
}
#[rocket::async_trait]
impl<D> Fairing for SessionFairing<D>
where
D: 'static + Sync + Send + Default,
where
D: 'static + Sync + Send + Default,
{
fn info(&self) -> Info {
Info {
name: "Session",
kind: fairing::Kind::Attach | fairing::Kind::Response,
kind: fairing::Kind::Ignite | fairing::Kind::Response,
}
}
fn on_attach(&self, rocket: Rocket) -> Result<Rocket, Rocket> {
async fn on_ignite(&self, rocket: Rocket<Build>) -> Result<Rocket<Build>, Rocket<Build>> {
// install the store singleton
Ok(rocket.manage(SessionStore::<D> {
inner: Default::default(),
lifespan: self.lifespan,
config: self.config.clone(),
}))
}
fn on_response<'r>(&self, request: &'r Request, response: &mut Response) {
async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response) {
// send the session cookie, if session started
let session = request.local_cache(|| SessionID("".to_string()));
if !session.0.is_empty() {
response.adjoin_header(Cookie::build(SESSION_COOKIE, session.0.clone()).finish());
response.adjoin_header(
Cookie::build((self.config.cookie_name.clone(), session.to_string()))
.path("/")
.finish(),
);
}
}
}