Cookbook / Auth and sessions

Cookies, sessions and CSRF

A browser login flow with server-side sessions, signed cookies and CSRF protection.

For browser apps, three middleware work together. CookieManager reads and writes cookies (plain, signed or encrypted), Sessions keeps server-side state keyed by a cookie, and Csrf blocks forged cross-site form posts: every POST, PUT, PATCH and DELETE must echo back the token it was given.

src/main.rs
use axeon::cookies::{Cookie, CookieManager, Key};
use axeon::csrf::Csrf;
use axeon::session::{MemoryStore, Sessions};
use axeon::{Html, Response, Server, ServerError};
use serde::Deserialize;

#[derive(Deserialize)]
struct Login {
    username: String,
}

fn main() {
    let mut app = Server::new();

    // Load the key from configuration in production so sessions survive restarts.
    app.middleware(CookieManager::new().key(Key::generate()))
        .middleware(Sessions::new(MemoryStore::new()).secure(!cfg!(debug_assertions)))
        .middleware(Csrf::new());

    // The form carries the CSRF token in a hidden field.
    app.get("/login", |req| async move {
        let token = req.csrf_token().unwrap_or_default().to_string();
        Html(format!(
            r#"<form method="post" action="/login">
                 <input type="hidden" name="_csrf" value="{token}">
                 <input name="username" placeholder="Your name">
                 <button>Log in</button>
               </form>"#
        ))
    });

    app.post("/login", |req| async move {
        let login: Login = req.form()?;
        // Check the credentials here, then start a fresh session.
        let session = req.session()?;
        session.regenerate(); // a new session ID on login prevents fixation
        session.insert("username", login.username)?;
        req.cookies().signed().add(Cookie::new("theme", "dark"));
        Ok::<_, ServerError>(Response::redirect("/me"))
    });

    app.get("/me", |req| async move {
        let name: Option<String> = req.session()?.get("username");
        Ok::<_, ServerError>(match name {
            Some(name) => format!("signed in as {name}"),
            None => "not signed in".to_string(),
        })
    });

    app.post("/logout", |req| async move {
        req.session()?.destroy();
        Ok::<_, ServerError>(Response::redirect("/login"))
    });

    app.listen("127.0.0.1:3000").expect("server failed to start");
}
Terminal
# Fetch the form (this sets the CSRF cookie), then post it back with the token.
TOKEN=$(curl -s -c jar http://127.0.0.1:3000/login | grep -o 'value="[^"]*"' | cut -d'"' -f2)
curl -s -b jar -c jar -d "_csrf=$TOKEN&username=ada" http://127.0.0.1:3000/login -L
curl -s -b jar http://127.0.0.1:3000/me                      # signed in as ada
curl -s -o /dev/null -w '%{http_code}\n' -X POST -d 'x=1' http://127.0.0.1:3000/logout   # 403: no token

Notes#

  • Single-page apps can read the csrf_token cookie from JavaScript and send it as the X-CSRF-Token header. APIs that use bearer tokens instead of cookies do not need Csrf at all.
  • MemoryStore is fine for one instance. For several, implement the SessionStore trait on Redis or your database.
  • signed() cookies can be read but not altered by the client; private() cookies are also encrypted.
  • Sessions are marked Secure in release builds, so they are only sent over HTTPS. Serve over TLS, or terminate it at your proxy.