rustlavel-auth 0.7.4

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());
    }

    #[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));
    }
}