Qirava DMS docs
Studio authentication: sessions, cookies, CSRF
Studio authentication is a thin, secure wrapper over the engine's own auth functions. The login form POSTs credentials to the same auth.login function the API uses; on success Studio sets the session token as an HttpOnly cookie named qdms_session and 302-redirects to the dashboard. Every other /studio/* page is gated by the auth.session before function (which validates the cookie and injects {user, role} into the request context) and the auth.session_extend after function (which bumps the idle window). CSRF protection on the authenticated mutations is stateless and session-bound: the token is an HMAC of the session token, so a cross-site forger — who cannot read the HttpOnly cookie — cannot forge it. This page walks through the login/logout handlers, the cookie, the session lifecycle, and CSRF, with the exact requests and responses.
The login route#
Login lives on a single path, /studio/login, served two ways. GET renders the username/password form (with any ?flash= or ?error= message). POST parses the form and authenticates. This is the ONE unauthenticated /studio/* route — it is deliberately NOT in the session-gate group, because it is the entry that mints the session in the first place. The login document uses StudioShell::auth_document, which carries the brand and the form centered on the page but NO session chrome (no sidebar, no header, no logout).
| Method | Path | Handler | In session group? |
|---|---|---|---|
| GET | /studio/login | studio.login | no (public) |
| POST | /studio/login | studio.login | no (public) |
| POST | /studio/logout | studio.logout | yes (so the cookie is read) |
The POST handler reads username and password from the form. If either is empty it re-renders the form with "Enter a username and password." Otherwise it calls auth.login through the runtime — the exact same function the API login route uses — which verifies the PBKDF2 password hash against _sys_users and mints a session token. On any non-Ok result it re-renders with a constant, non-enumerating message ("Invalid username or password.") so login does not leak whether a username exists.
What auth.login does#
auth.login verifies {username, password} against the _sys_users catalog using PBKDF2 (qcrypto::password_verify), and to resist user-enumeration via timing it runs a dummy verify even when the user is missing. On success it mints a 32-byte random token, stores only the SHA-256 HASH of that token in _sys_sessions (never the plaintext), records created_us and an expires_us idle deadline, and returns the plaintext token ONCE.
Request
{ "username": "ada", "password": "<password>" }Response
{
"token": "<64-hex-char opaque token>",
"user": "ada",
"role": "custodian",
"expires_us": 1719500000000000
}Studio takes the token from that response and sets it as the qdms_session cookie. It does NOT echo the token into the page. The Max-Age mirrors SESSION_IDLE_US, which is 30 minutes (30 * 60 * 1_000_000 microseconds), so the cookie's max age is 1800 seconds.
- _sys_sessions schema
- { token_hash, user, role, created_us, expires_us }. Only the SHA-256 hash of the bearer token is stored; the plaintext lives only in the user's cookie.
- Token shape
- hex of 32 random bytes minted by qcrypto::random_bytes. Opaque; the server looks it up by its SHA-256 hash.
The session cookie#
On a successful login Studio builds the Set-Cookie via session_cookie_header(token, max_age, secure). The cookie is HttpOnly (JavaScript cannot read it — this is what makes the CSRF scheme safe), SameSite=Lax (so the top-level login redirect carries it), Path=/, and Max-Age in seconds. The Secure flag is added when secure is set (it is off in tests; turn it on behind TLS in production).
qdms_session=<token>; HttpOnly; SameSite=Lax; Path=/; Max-Age=1800- Cookie name
- qdms_session (the SESSION_COOKIE constant).
- HttpOnly
- Not readable by scripts. An attacker's origin cannot read the cookie, which is exactly why a derived CSRF token cannot be forged cross-site.
- SameSite=Lax
- Sent on top-level navigations (so the post-login redirect to /studio carries it) but not on cross-site subrequests.
- Max-Age=0 (logout)
- clear_cookie_header writes the same cookie with an empty value and Max-Age=0, which immediately expires it.
The session gate: before and after#
Every authenticated /studio/* route is covered by studio_session_group(), a worker Group whose before chain runs auth.session and whose after chain runs auth.session_extend, both in Chain mode.
Group::new("studio_session")
.for_routes(STUDIO_ROUTE_IDS)
.before(ChainMode::Chain, vec![Bound::wait(AUTH_SESSION)])
.after(ChainMode::Chain, vec![Bound::wait(AUTH_SESSION_EXTEND)])auth.session reads the bearer token (Authorization header) or, failing that, the qdms_session cookie. It hashes the token, looks up the session row by token_hash, and rejects if absent or if expires_us <= now. Crucially it then RE-READS the user's current _sys_users row to get the LIVE role and enabled flag — the role captured in the session at login time is NOT trusted for the gate. This means a demotion, role change, or disable takes effect on the very next request rather than after the up-to-30-minute idle window. On success it injects {user, role} into the shared ctx for the page handler to read.
Request
GET /studio/users HTTP/1.1
Cookie: qdms_session=<opaque-token>Response
// auth.session emits, into the shared ctx:
{ "user": "ada", "role": "custodian" }Output
// On no/invalid/expired session, auth.session denies:
// AccessDenied → the live HTTP layer maps it to 401.
// The page handler's own require_user check ALSO redirects to
// /studio/login as defense-in-depth (see studio-rbac).auth.session_extend is the after fn: on a successful request it bumps the session's expires_us forward by the idle window (SESSION_IDLE_US, 30 minutes), via an UPDATE guarded by expires_us > now so it cannot resurrect an already-expired session. Thirty minutes of inactivity therefore logs a user out automatically.
Logout#
Logout is POST /studio/logout. The header's Logout button is a small <form> that carries the session-bound CSRF token. The handler reads the session cookie and the submitted _csrf field; if a session exists it requires a valid CSRF token (this defends against a forced-logout CSRF). It then clears the cookie and redirects to the login page with a flash.
Request
POST /studio/logout HTTP/1.1
Cookie: qdms_session=<opaque-token>
Content-Type: application/x-www-form-urlencoded
_csrf=<session-bound-csrf>Response
HTTP/1.1 302 Found
Location: /studio/login?flash=You+have+been+logged+out
Set-Cookie: qdms_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0Output
// If a session exists but the CSRF token is bad:
// 302 → /studio/login?error=Bad+CSRF+token (cookie NOT cleared)
// If there is no session at all: just bounce to login (nothing to protect).CSRF: stateless and session-bound#
Studio protects its authenticated mutations (and logout) with a CSRF token that requires no server-side storage. The token is derived from the session token itself: csrf_token = HMAC-SHA256(session_token, "qdms-csrf"), rendered as hex. It is bound to the session, which lives in the HttpOnly cookie an attacker's origin cannot read — so a cross-site forger cannot produce a valid token. On each POST the server recomputes the expected token from the cookie and compares it to the submitted _csrf field in constant time (qcrypto::ct_eq_str).
pub fn csrf_token(session_token: &str) -> String {
qcrypto::hmac_sha256_hex(session_token.as_bytes(), b"qdms-csrf")
}
pub fn csrf_verify(session_token: &str, submitted: &str) -> bool {
if session_token.is_empty() || submitted.is_empty() {
return false; // fail closed
}
qcrypto::ct_eq_str(&csrf_token(session_token), submitted)
}- Hidden field name
- _csrf (the CSRF_FIELD constant). Every Studio form embeds <input type="hidden" name="_csrf" value="…">.
- Stateless
- Nothing is stored server-side; the expected token is recomputed from the cookie on each POST.
- Session-bound
- A token minted for one session fails for any other; an empty session or empty submission fails closed.
- Constant-time compare
- ct_eq_str avoids leaking the token via timing.