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.