rustlavel-auth 0.9.1

Rustlavel authentication: sessions, argon2 hashing, CSRF, signed URLs, encryption
Documentation
//! Sessions held in Redis, for an application running more than one process.
//!
//! [`FileStore`](crate::store::FileStore) keeps a session on the local disk,
//! which is right for one machine and wrong for two: a session written by one
//! process is invisible to the other, so a visitor behind a load balancer is
//! signed in on every other request. This is the store for that case.
//!
//! **Redis does the expiry.** Every write sets `PX`, so a session that stops
//! being touched disappears on its own. That makes [`gc`](RedisSessions::gc) a
//! no-op rather than a sweep, and it removes the failure the file store has to
//! think about — a directory quietly filling with one dead session per visitor.
//!
//! The client comes from `rustlavel-cache`, which already speaks RESP and
//! pools connections; the alternative was a second Redis implementation in a
//! second crate, and two of them would drift.

use std::time::Duration;

use rustlavel_cache::redis::RedisStore as RedisClient;
use rustlavel_core::{Json, Result};

use crate::session::Session;
use crate::store::{DEFAULT_LIFETIME, SessionStore, StoreFuture};

/// Sessions in Redis, keyed by id under a prefix.
///
/// Cloning shares one connection pool, so this can be built once and handed to
/// the session middleware.
#[derive(Clone)]
pub struct RedisSessions {
    client: RedisClient,
    prefix: String,
    lifetime: Duration,
}

impl RedisSessions {
    /// Connect with a URL: `redis://[:password@]host:port[/db]`.
    ///
    /// Nothing is opened here — the pool connects on first use, and
    /// [`verify`](RedisSessions::verify) is how an application finds out at
    /// boot rather than on somebody's first sign-in.
    pub fn connect(url: &str) -> Result<RedisSessions> {
        Ok(RedisSessions {
            client: RedisClient::connect(url)?,
            prefix: "session:".into(),
            lifetime: DEFAULT_LIFETIME,
        })
    }

    /// Build on a client the application already has.
    ///
    /// Sharing the cache's pool is usually what you want: one set of
    /// connections, one place the URL is configured.
    pub fn new(client: RedisClient) -> RedisSessions {
        RedisSessions { client, prefix: "session:".into(), lifetime: DEFAULT_LIFETIME }
    }

    /// The key prefix. `session:` unless you say otherwise.
    ///
    /// Worth setting when one Redis serves two applications: without it they
    /// share a keyspace and sign each other's visitors in.
    pub fn prefix(mut self, prefix: impl Into<String>) -> RedisSessions {
        self.prefix = prefix.into();
        self
    }

    pub fn lifetime(mut self, lifetime: Duration) -> RedisSessions {
        self.lifetime = lifetime;
        self
    }

    /// Open a connection now, so a wrong URL or password is a boot failure
    /// rather than a signed-out visitor an hour later.
    pub async fn verify(&self) -> Result<()> {
        self.client.verify().await
    }

    fn key(&self, id: &str) -> String {
        format!("{}{id}", self.prefix)
    }
}

impl SessionStore for RedisSessions {
    fn read<'a>(&'a self, id: &'a str) -> StoreFuture<'a, Option<Session>> {
        Box::pin(async move {
            // The same shape check the file store makes before touching disk:
            // an id this framework did not generate cannot name a session, and
            // asking Redis about it would only tell an attacker how long a
            // lookup takes.
            if !crate::session::is_valid_id(id) {
                return Ok(None);
            }

            let reply = self
                .client
                .pool()
                .command(&[b"GET", self.key(id).as_bytes()])
                .await?
                .into_result()?;

            // Redis expired it, or it never existed. There is no third case
            // here, which is the point of letting Redis own the lifetime.
            let Some(source) = reply.as_str() else { return Ok(None) };
            let Ok(payload) = Json::parse(source) else {
                // Unparseable: signed out rather than a failed request, and the
                // key is dropped so it cannot fail the same way again.
                let _ = self.destroy(id).await;
                return Ok(None);
            };

            Ok(Some(Session::from_json(id, &payload)))
        })
    }

    fn write<'a>(&'a self, session: &'a Session) -> StoreFuture<'a, ()> {
        Box::pin(async move {
            if !crate::session::is_valid_id(session.id()) {
                return Err(rustlavel_core::Error::msg(
                    "refusing to write a session whose id was not generated by this framework",
                ));
            }

            let mut stamped = session.clone();
            stamped.touch();

            // `PX` on every write is what makes the lifetime a sliding window:
            // each request the session is written on pushes the expiry out.
            let millis = self.lifetime.as_millis().to_string();
            self.client
                .pool()
                .command(&[
                    b"SET",
                    self.key(session.id()).as_bytes(),
                    stamped.to_json().to_string().as_bytes(),
                    b"PX",
                    millis.as_bytes(),
                ])
                .await?
                .into_result()?;
            Ok(())
        })
    }

    fn destroy<'a>(&'a self, id: &'a str) -> StoreFuture<'a, ()> {
        Box::pin(async move {
            if crate::session::is_valid_id(id) {
                self.client.pool().command(&[b"DEL", self.key(id).as_bytes()]).await?.into_result()?;
            }
            Ok(())
        })
    }

    fn gc(&self) -> StoreFuture<'_, usize> {
        // Nothing to collect. Every key carries `PX`, so Redis removes it on
        // its own — and a `SCAN` over the keyspace to find what is already gone
        // would be work with no result. Zero here means "none were removed",
        // which is true.
        Box::pin(async move { Ok(0) })
    }

    fn lifetime(&self) -> Duration {
        self.lifetime
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The key an id lands under, and the prefix that keeps two applications
    /// on one Redis from signing each other's visitors in.
    #[test]
    fn keys_are_prefixed_and_the_prefix_can_be_changed() {
        let sessions = RedisSessions::connect("redis://127.0.0.1:6379").unwrap();
        assert_eq!(sessions.key("abc"), "session:abc");

        let namespaced = sessions.prefix("pamjaya:session:");
        assert_eq!(namespaced.key("abc"), "pamjaya:session:abc");
    }

    /// An id this framework did not mint must not reach Redis at all — the
    /// file store makes the same check before it touches a path.
    #[tokio::test]
    async fn an_id_of_the_wrong_shape_is_refused_without_asking_redis() {
        // Port 1 is never a Redis. If the shape check were missing, this would
        // fail trying to connect rather than returning `None`.
        let sessions = RedisSessions::connect("redis://127.0.0.1:1").unwrap();
        assert!(sessions.read("../../etc/passwd").await.unwrap().is_none());
        assert!(sessions.read("short").await.unwrap().is_none());
    }

    /// Against a live server, when there is one. `REDIS_URL` names it; a
    /// `valkey://` URL works, and the suite is run that way on purpose.
    ///
    /// Until this test existed the Redis session store had never been
    /// exercised against a server by the suite — only its key shape and its
    /// refusal of malformed ids. A store that is shipped for production and
    /// tested only in the ways that need no server is a store whose one
    /// important claim is untested.
    #[tokio::test]
    async fn a_session_survives_the_round_trip_on_a_live_server() {
        let Ok(url) = std::env::var("REDIS_URL") else {
            println!("skipped: set REDIS_URL to run the live session-store test");
            return;
        };

        // Its own prefix, so two runs of the suite against one server — or
        // this test and the cache's — cannot read each other's rows.
        let sessions = RedisSessions::connect(&url)
            .unwrap()
            .prefix(format!("test:{}:session:", std::process::id()))
            .lifetime(Duration::from_secs(60));

        let mut session = Session::new();
        session.put("user_id", 42);
        session.put("theme", "dark");
        let id = session.id().to_string();

        sessions.write(&session).await.expect("written");

        let found = sessions.read(&id).await.expect("read").expect("the session is there");
        assert_eq!(found.id(), id);
        assert_eq!(found.get("user_id").and_then(|v| v.as_i64()), Some(42));
        assert_eq!(found.get("theme").and_then(|v| v.as_str()), Some("dark"));

        sessions.destroy(&id).await.expect("destroyed");
        assert!(sessions.read(&id).await.expect("read").is_none(), "a destroyed session came back");
    }

    #[test]
    fn the_lifetime_is_the_one_the_middleware_will_ask_for() {
        let sessions = RedisSessions::connect("redis://127.0.0.1:6379")
            .unwrap()
            .lifetime(Duration::from_secs(1800));
        assert_eq!(SessionStore::lifetime(&sessions), Duration::from_secs(1800));
    }
}