Ignite rocketboost.rs

Panel

The panel is the admin area of the app: its address, its configuration, its sign-in, and the messages it shows after a change.

Address

The panel mounts under /admin. The mount method changes the address, with or without a trailing slash:

use ignite_panel::Ignite;

/// The panel at its own address, instead of the default `/admin`.
pub fn panel() -> Ignite {
    Ignite::new().mount("/backoffice")
}

The panel's routes are:

  • GET /admin, the dashboard.
  • GET /admin/login, POST /admin/login, POST /admin/logout and POST /admin/logout-everywhere, for sign-in, sign-out and ending every session of the user.
  • GET /admin/<slug>, the list of a resource, with q, sort, dir and page in the query string.
  • GET /admin/<slug>/new and POST /admin/<slug>, to create a row.
  • GET /admin/<slug>/<id>/edit and POST /admin/<slug>/<id>, to update a row.
  • POST /admin/<slug>/<id>/delete, to delete a row.

Configuration

These environment variables matter. They can come from the environment or from a .env file in development:

  • DATABASE_URL, a mysql:// URL. The panel panics at startup if it is missing or the database cannot be reached.
  • IGNITE_SECRET_KEY, a base64 string of 32 bytes. It signs the sign-in and flash cookies. Create one with openssl rand -base64 32. A release build stops at startup without a valid key. A debug build uses a random key for that run, with a warning, so sign-ins end when the app restarts.
  • IGNITE_VIEWS_DIR, a folder of the panel's views that replaces the embedded ones, for editing views without a rebuild. It works in debug builds only; a release build ignores it. See Views.

Sign-in

The panel's users live in the users table, which the panel owns: an id, a name, an email address that must be unique, a password hash and a creation time. Passwords are hashed with argon2.

Sign in at /admin/login. A wrong email or password shows the same message for both, so the page does not reveal which emails are registered.

A sign-in is a row in the sessions table, and it lasts eight hours. The browser holds only a signed cookie with the row's id, and the panel checks the row on each request. Sign-out deletes the row. The header's Sign out everywhere button, which posts to /admin/logout-everywhere, deletes every session of the signed-in user.

Every page under the panel needs a session, except sign-in and sign-out. Without one, the request redirects to /admin/login.

The panel has no sign-up page, no password reset, no remember-me and no roles. To add a user, use the command-line tool in Installation. It checks the configuration and the email before it asks for the password:

cargo run --bin ignite -- user create --email you@example.com --name "You"

Forgery protection

Each browser has a token, held in the signed ignite_csrf cookie and created on the first panel page it renders. Every panel form carries it as a hidden _token field. Sign-in, sign-out, log out everywhere, create, update and delete check the token before they do anything. A missing or wrong token gets status 419 and a Page Expired page. Your app's own routes are checked the same way, as the next section shows.

Login throttle

Each failed sign-in counts against its email, five per 60 seconds, and against its IP address, twenty per 60 seconds. The counts are kept in the login_throttles table, so they hold across restarts and processes. While either count is at its limit, sign-in is refused with status 429, a Retry-After header with the seconds to wait, and the sign-in page. A successful sign-in clears the email's count. Requests whose address is unknown share one IP count.

Your own pages

A route of the app under the panel's address can require a sign-in. Take ignite_panel::AdminUser as an argument, and it is a request guard: without a session the request redirects to the sign-in page. user_id() returns the signed-in user's id. It renders the view in Views:

use ignite_panel::AdminUser;
use propel_rocket::PropelView;
use serde::Serialize;

#[derive(Serialize)]
pub struct Account {
    pub user_id: i64,
}

/// An app page under the panel's mount. The `AdminUser` guard redirects to the
/// sign-in page when nobody is signed in.
#[rocket::get("/admin/account")]
pub fn account(session: AdminUser) -> PropelView {
    PropelView::render(
        "account",
        &Account {
            user_id: session.user_id(),
        },
    )
}

Forms of your own

An app route that takes a form checks the panel's token. Its data guard is ignite_panel::CsrfForm, which reads _token from the form, or the X-CSRF-TOKEN header, and answers 419 when it is missing or wrong. The page gets the token from ignite_panel::CsrfToken, and writes it into a hidden field. The guard covers POST, PUT, PATCH and DELETE routes.

use ignite_panel::{CsrfForm, CsrfToken};
use propel_rocket::PropelView;
use serde::Serialize;

#[derive(Serialize)]
pub struct Checkout {
    pub csrf_token: String,
}

/// The form page. The token comes from the request guard, and the view writes
/// it into a hidden `_token` field.
#[rocket::get("/checkout")]
pub fn checkout(token: CsrfToken) -> PropelView {
    PropelView::render(
        "checkout",
        &Checkout {
            csrf_token: token.value().to_string(),
        },
    )
}

/// The form's POST route. `CsrfForm` checks the token before the handler runs,
/// and answers 419 when it is missing or wrong.
#[rocket::post("/checkout", data = "<form>")]
pub fn place_order(form: CsrfForm) -> String {
    form.get("item").unwrap_or_default().to_string()
}

/// A webhook from another service. It has no form token, so the app exempts
/// `/hooks/*` in the builder.
#[rocket::post("/hooks/payment")]
pub fn payment_hook() -> &'static str {
    "ok"
}

The form writes the token from its context:

@extends('layouts.app')

@section('title')
  Checkout
@endsection

@section('content')
  <form method="post" action="/checkout">
    <input type="hidden" name="_token" value="{{ $csrf_token }}">
    <input type="text" name="item">
    <button type="submit">Order</button>
  </form>
@endsection

A route that receives webhooks from another service has no form token. Name its path in the builder with csrf_exempt, as .csrf_exempt(&["/hooks/*"]) does here. A path ending in /* covers everything under it. The panel's own routes are always checked.

Messages

After a change, the panel shows a message on the next page. See Notifications.