Middleware

The back office can upload posters, import seasons, and export orders, and it's still open to anyone on the office network. Let's lock it, and keep a log of everything the staff do.

A shared staff token

For now, the staff share one secret token. A singleton holds it, and compares tokens in constant time, so response times don't give the token away:

use margaret::framework::macros::constructor;
use margaret::framework::macros::singleton;
use margaret::framework::token_digest::equal_in_constant_time::equal_in_constant_time;

#[singleton]
pub struct StaffToken {
    value: String,
}

impl StaffToken {
    #[constructor]
    pub fn create(
        #[environment_variable(from = "STAFF_TOKEN")] value: String,
    ) -> anyhow::Result<Self> {
        Ok(Self { value })
    }

    pub fn matches(&self, presented: &str) -> bool {
        equal_in_constant_time(presented.as_bytes(), self.value.as_bytes())
    }
}

A small form on the admin server takes the token, and stores it in a cookie. host_cookie builds a cookie that is HttpOnly, Secure, and SameSite=Lax:

#[singleton]
#[middleware(audit_log)]
#[responds_to_http(
    max_body_bytes = 1_024,
    method = RouteMethod::Post,
    name = "sign_in_staff",
    path = "/sign-in",
    server = "admin"
)]
pub struct SignInStaff {
    staff_token: Arc<StaffToken>,
}

impl SignInStaff {
    #[constructor]
    pub fn create(staff_token: Arc<StaffToken>) -> anyhow::Result<Self> {
        Ok(Self { staff_token })
    }

    #[process]
    pub fn respond(
        &self,
        routes: &Routes,
        #[form_request(from = RequestInput::Form)] StaffSignInForm { token }: StaffSignInForm,
    ) -> anyhow::Result<Response> {
        if !self.staff_token.matches(&token) {
            return Ok(Response::unauthorized());
        }

        Ok(routes
            .admin
            .show_dashboard
            .see_other()
            .into_response()
            .set_cookie(&host_cookie("staff_token".to_string(), token).build()))
    }
}

A GET /sign-in route next to it, named show_staff_sign_in, renders the form.

Writing middleware

Middleware wraps a route: it can look at the request, stop it, or let it through. It's a struct with #[handles_middleware_attribute], and the attribute is the tag routes will use. Like a singleton, it can have a constructor:

#[handles_middleware_attribute(attribute = staff_only)]
pub struct StaffOnly {
    staff_token: Arc<StaffToken>,
}

impl StaffOnly {
    #[constructor]
    pub fn create(staff_token: Arc<StaffToken>) -> anyhow::Result<Self> {
        Ok(Self { staff_token })
    }

    #[process]
    pub async fn process(
        &self,
        request: &Request,
        routes: &Routes,
        #[form_request(from = RequestInput::Cookie)] cookie: StaffCookie,
        next: Next,
    ) -> anyhow::Result<ResponseContinuation> {
        match cookie.staff_token {
            Some(token) if self.staff_token.matches(&token) => Ok(next.run(request).await?),
            _ => Ok(routes.admin.show_staff_sign_in.see_other().into()),
        }
    }
}

Call next.run(request) to continue to the route, or return your own response to stop here. Here, anyone without the token is sent to the sign-in form.

Middleware is a role of its own, so don't add #[singleton] to it. Margaret builds it once, like a singleton, and the build would otherwise complain that nothing uses that singleton.

And a second one, which logs every staff request:

use margaret::framework::http::next::Next;
use margaret::framework::http::request::Request;
use margaret::framework::http::response_continuation::ResponseContinuation;
use margaret::framework::macros::handles_middleware_attribute;
use margaret::framework::macros::process;

#[handles_middleware_attribute(attribute = audit_log)]
pub struct AuditLog;

impl AuditLog {
    #[process]
    pub async fn process(
        &self,
        request: &Request,
        next: Next,
    ) -> anyhow::Result<ResponseContinuation> {
        let server = &request.inputs.server;

        println!(
            "staff request from {}: {} {}",
            server.remote_addr(),
            server.method(),
            server.path()
        );

        Ok(next.run(request).await?)
    }
}

Middleware runs before the route reads the request body, so it can't ask for JSON or uploads.

Using middleware

Routes opt in with #[middleware], imported from margaret::framework::macros like every other attribute:

#[singleton]
#[middleware(audit_log)]
#[middleware(staff_only)]
#[responds_to_http(
    method = RouteMethod::Get,
    name = "show_dashboard",
    path = "/",
    server = "admin"
)]

The first tag listed runs first and wraps the others. Here, audit_log runs before staff_only, so refused attempts land in the log too:

staff request from 10.0.0.23:40528: GET /
staff request from 10.0.0.23:40540: GET /sign-in
staff request from 10.0.0.23:40550: POST /sign-in

Every admin route gets both tags, including a new one that lets the staff cancel an order. Deleting an order deletes its tickets too, because tickets declared OnDelete::Cascade:

#[singleton]
#[middleware(audit_log)]
#[middleware(staff_only)]
#[responds_to_http(method = RouteMethod::Delete, path = "/orders/{order}", server = "admin")]
pub struct CancelOrder {
    database: Arc<Database>,
}

impl CancelOrder {
    #[constructor]
    pub fn create(database: Arc<Database>) -> anyhow::Result<Self> {
        Ok(Self { database })
    }

    #[process]
    pub async fn respond(
        &self,
        #[route_parameter(from = "order")] order: Order,
    ) -> anyhow::Result<Response> {
        Ok(match order.delete(self.database.as_ref()).await? {
            Deletion::Deleted => {
                Response::text(200, "The order is cancelled, and its seats are free again.")
            }
            Deletion::Missing => Response::not_found(),
        })
    }
}

Every route lists exactly the middleware it runs, so you can always tell what happens to a request by reading its route. Using a tag that no middleware handles is a build error.

A shared token is a start, but everyone knows it, and nobody can tell staff members apart. Staff sign-in replaces it with real accounts.