Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Sessions and CSRF

A session cookie signs an operator in. Alongside the identity it carries a CSRF token and flash messages.

CSRF

Every POST, PUT, PATCH and DELETE on the admin mount passes two gates. GET, HEAD, OPTIONS and QUERY are exempt.

GatePasses when
OriginSec-Fetch-Site: same-origin, or Origin matches app.url. In development the request Host stands in.
TokenThe session’s token arrives in the _csrf field or the X-CSRF-Token header.

Descriptor forms and htmx requests carry the token already. A hand-written form includes it:

<form method="post" action="{{ action }}">
  {% include "_csrf.html" %}
  ...
</form>

A failed gate renders a 403 “Request blocked” page. Login and first-run setup have no session and pass on origin alone.

Flash a message

use laterite_admin::{FlashLevel, SessionHandle};

async fn save(Extension(session): Extension<SessionHandle>, /* ... */) -> Response {
    // ... persist ...
    session.push_flash(FlashLevel::Success, "Settings saved.");
    Redirect::to("/admin/settings").into_response()
}

The next full page renders and clears it, across a redirect. Levels: Success, Error, Info. push_flash_sticky keeps the message until dismissed.

Change a password

use laterite_core::Actor;

auth.change_password(
    user.id,
    &new_password,
    Some(&session_token),
    &Actor::user(user.id, &user.username),
)
.await?;
ArgumentValue
user_idThe account whose password changes.
new_passwordRefused under password_policy.min_length (default 8).
keep_tokenThe session to keep signed in, or None to end all of them.
actorWho made the change, recorded on the audit log. Actor::system("lat admin reset-password") for a process.

AuthService::password_changed_at(user_id) returns when it last changed, or None if never recorded.

An operator changes their own under Preferences → Password, which calls:

auth.change_own_password(user.id, &current, &new_password, &session_token, &ctx)
    .await?;
ErrorReturned when
AuthError::InvalidCredentialscurrent does not match. Counts as a failed sign-in.
AuthError::TooManyAttemptsThe account is locked out.
AuthError::RefusedThe new password is under password_policy.min_length.

A change through this call is also on the access log as password_changed, with the address it came from.

A password made for an operator is temporary:

auth.require_password_change(user.id).await?;

Every admin request then redirects to Preferences → Password until the operator sets their own. lat admin create --generate and lat admin reset-password --generate mark the account the same way.

Purge

Expired sessions and stay-signed-in credentials are removed at boot, every hour, and on demand:

lat admin purge
let purged = auth.purge_expired().await?;
println!("{} sessions, {} credentials", purged.sessions, purged.remember_tokens);

Why a session ended

EventThe signed-out device sees
AuthService::change_password“Your password was changed”; every other session and stay-signed-in device ends.
Deactivation“This account was deactivated.”
Sign out one device, or every other device“You were signed out from another device.”
Inactivity or the absolute ceiling“Your session ended after a period of inactivity.”

The login URL carries ?ended=<code>, from a fixed set. An htmx request gets HX-Redirect with no body.

A fresh login mints a new session and token. A corrupt session blob degrades to an empty session, never a sign-out.