I want to implement sessions in my API (follow up from this question). Actix has some very handy middleware for this which I'd use in a real-life scenario. However, for the sake of learning and because it's really interesting, I want to implement this manually.
I've written a module to handle the sessions.
lazy_static! {
// Session database saved in memory
static ref SESSIONS : Mutex<HashMap<String, Option<u8>>> = Mutex::new(HashMap::new());
}
// Module for handling sessions
pub mod session_handler {
use actix_web::{HttpRequest, HttpResponse};
use rand::{thread_rng, Rng};
use rand::distributions::Alphanumeric;
use sha2::{Sha256, Digest};
use crate::*;
// Generate session_token. Concatenation of sha256(ip_address) and a random string,
// ip_address is included to avoid collission
fn generate_session_token(ip_address: String) -> String {
// Hashing ip_address
let mut sha256 = Sha256::new();
sha256.update(ip_address);
let ip_address_hash : String = format!("{:X}", sha256.finalize());
// Random String
let random_string : String = thread_rng()
.sample_iter(&Alphanumeric)
.take(32)
.collect();
format!("{}{}", ip_address_hash, random_string)
}
// Check if a session exists by reading the cookie 'session' in the request-header
pub fn session_exists(req: HttpRequest) -> bool {
let exists = get_session_token(req);
if exists == None {
return false
}
if exists.unwrap() == "" {
return false
}
true
}
// Retrieves the session_token from the request.
// if none cookie is found this Option<String> will be None, otherwise
// it will contain the session_token after unwrapping
pub fn get_session_token(req: HttpRequest) -> Option<String> {
req.headers()
.get("session")
.and_then(|r| r.to_str().map(String::from).ok())
}
// Function to make a HttpResponse with the NEW session_token in the header.
pub fn set_session_token(req: HttpRequest) -> HttpResponse {
let ip_address = req
.connection_info()
.remote_addr()
.unwrap()
.to_string();
let session_token = generate_session_token(ip_address);
let header_string = format!("session={}; Secure; HttpOnly", session_token);
add_session(session_token);
HttpResponse::Ok()
.content_type("plain/text")
.header("Set-Cookie", header_string)
.body("")
}
// Function to save this session in the session_database
pub fn add_session(token: String) {
SESSIONS.lock().unwrap().insert(token, None);
}
// Function to delete a session from the session_database
pub fn delete_session_by_token(token: String) {
SESSIONS.lock().unwrap().remove(&token);
}
// Unimplemented...
pub fn delete_session_by_userid(id: u8) {
unimplemented!()
}
}
The flow of a request would go like this.
Pseudo-code:
// Function executed after Actix' route() function
async fn view_secret_page(req: HttpRequest) -> HttpResponse {
// If the user has session
if session_exists(req) {
// Check if he's logged in
let user_id = get_user_by_session(get_session_token(req));
if user_id == None {
// Not authenticated
return;
} else {
// Show secret page because user is logged in!
// Return HttpOK
}
} else {
// No session is set... So start session for user
set_session_token(req) // returns HttpResponse with cookie in header
}
}
I'd probably implement this in middleware so this is automated by each request and I don't have to do all the checks for each request.
Before continuing the implementation, I'd like to receive some feedback on my module. Is it workable? What can be improved?
Using a static to implement middleware is definitely not recommended, since all instances would share the same session list. It'd also be good to use a concurrent map implementation like dashmap to allow multiple users to connect to the server at once (which is kinda the point )
At a finer level, I think your token generation could be improved. Using uuid would keep it simple (while avoiding allocating each token too), and you can prevent collisions by making sure it isn't already in the session list. You can probably tune the hasher to be very quick since you're generating the keys, and they'll only be a few bytes long.
Then some style stuff:
fn session_exists(req: &HttpRequest) -> bool {
get_session_token(req).map_or(false, |s| s != "")
}
fn get_session_token(req: &HttpRequest) -> Option<&str> {
req.headers().get("session").and_then(|r| r.to_str())
// and maybe .parse().ok() if you're using Uuids
}
And it looks like the code above is using even more random bits than the ~122 in a UUID. Though it should really just use a v4 UUID directly, rather than reinventing one poorly...
There are many ways to get collisions in UUIDs other than just bad luck. A bad guy could introduce one on purpose. A hot spare might necessarily use the same UUIDs as the primary. There can be an unknown flaw in the random number generator.
There's also what happened at the Lawrence Berkeley Lab Supercomputer Center. They had two 128K node systems that they assembled into a single 256K node system, but it wouldn't boot up. After a couple of weeks' effort, they discovered that two NICs had the same MAC address.
All that being said, I'm using UUIDs in my project with no provision for dealing with collisions.
I'll switch my token_generation to UUID. And how would I implement the dashmap? Can I also make that kind of global so that all functions can easily access it?
A dashmap (concurrent hashmap) allows mutation of the inner elements with only a &self (thus enabling multiple concurrent writes through the use of reader). It accmplishes this through the use of atomic locking mechanisms. You can (very roughly) emulate a concurrent hashmap by wrapping every T in a HashMap with a RwLock<T> or Mutex<T>, however, there are lock-free versions (e.g., evmap) that fundamentally use CAS operations.
Also, since you need concurrent read access to the DashMap, I wouldn't wrap SESSIONS with a Mutex; instead use a RwLock and call read() on it to access the dashmap (also, you might be able to get away with no outer wrapper; depends on whether or not DashMap implements the right traits). This will allow you to hold multiple read-locks which thereby enable you to mutate the inner elements of the DashMap
lazy_static! {
static ref SESSIONS : RwLock<DashMap<String, Option<u8>>> = RwLock::new(DashMap::new());
}
// Module for handling sessions
pub mod session_handler {
use actix_web::{HttpRequest, HttpResponse};
use rand::{thread_rng, Rng};
use rand::distributions::Alphanumeric;
use sha2::{Sha256, Digest};
use uuid::Uuid;
use crate::*;
// Generate session_token. Concatenation of sha256(ip_address) and a random string,
// ip_address is included to avoid collission
fn generate_session_token() -> String {
Uuid::new_v4().to_string()
}
// Check if a session exists by reading the cookie 'session' in the request-header
pub fn session_exists(req: HttpRequest) -> bool {
get_session_token(req).map_or(false, |s| s != "")
}
// Retrieves the session_token from the request.
// if none cookie is found this Option<String> will be None, otherwise
// it will contain the session_token after unwrapping
pub fn get_session_token(req: HttpRequest) -> Option<String> {
Some(req.headers().get("session").and_then(|r| r.to_str().ok()).unwrap().to_string())
}
// Make a HttpResponse with the NEW session_token in the header.
pub fn set_session_token(req: HttpRequest) -> HttpResponse {
let ip_address = req
.connection_info()
.remote_addr()
.unwrap()
.to_string();
let session_token = generate_session_token();
let header_string = format!("session={}; Secure; HttpOnly", session_token);
add_session(session_token);
HttpResponse::Ok()
.content_type("plain/text")
.header("Set-Cookie", header_string)
.body("")
}
// Save this session in the session_database
pub fn add_session(token: String) {
SESSIONS.read().unwrap().insert(token, None);
}
// Delete a session from the session_database
pub fn delete_session_by_token(token: String) {
SESSIONS.read().unwrap().remove(&token);
}
// Unimplemented...
pub fn delete_session_by_userid(id: u8) {
unimplemented!()
}
// A user is linked to a session after authenticating himself. This Function
// can retrieve which user is connected to which session.
pub fn get_user_by_session(token: String) -> Option<u8> {
*SESSIONS.read().unwrap().get(&token).unwrap()
}
pub fn set_user_by_session(token: String, user_id: u8) {
if let Some(mut value) = SESSIONS.read().unwrap().get_mut(&token) {
*value = Some(user_id);
}
}
}
If this is okay I'll try to actually implement it.