Login, logout, request.user
Anonymous sessions on every visit, the one-call login, axum extractors for `request.user`, and flash messages.
umbral-auth verifies credentials. umbral-sessions stores sessions
and reads cookies. Together they give you this shape: every
browser has a session from the first request (anonymous), login
transforms it into an authenticated session, and handlers extract
the current user via type-driven extractors.
A session is a browser, not an authentication
A session identifies the browser. The same session row spans the user's whole visit:
| State | user_id column | When |
|---|---|---|
| Anonymous | NULL | From the first page load. Backs flash messages, cart contents, CSRF tokens, anything that has to survive a single redirect. |
| Authenticated | Some(user.id) | After umbral_auth::login(...). Fresh token (session-fixation defense); the row's data survives the transition. |
SessionsPlugin::default() auto-applies a tower middleware
(session_layer) on the router so every request lands with a
SessionToken extension. Anonymous flash messages, cart, etc. all
"just work" - they're already there.
let app = App::builder() .plugin(AuthPlugin::<AuthUser>::default()) .plugin(SessionsPlugin::default()) // auto session-on-every-visit .routes(Routes::new().get("/", home)) .build()?;Opt-out for advanced setups (typically when you want sessions only
under /app/* and not for /api/healthz):
.plugin(SessionsPlugin::default().without_auto_layer())// then apply the layer to a sub-router yourself:// some_subrouter.layer(axum::middleware::from_fn(umbral_sessions::session_layer))Logging a user in
use umbral::prelude::*;use umbral::web::{Form, IntoResponse, Redirect, StatusCode}; #[derive(serde::Deserialize)]struct LoginForm { username: String, password: String,} async fn login(Form(form): Form<LoginForm>) -> Result<Response, StatusCode> { let user: AuthUser = umbral_auth::authenticate(&form.username, &form.password) .await .map_err(|_| StatusCode::UNAUTHORIZED)?; let mut response = Redirect::to("/").into_response(); umbral_auth::login(response.headers_mut(), &user) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(response)}umbral_auth::login(response_headers, &user) does three things in one call:
- Creates a session row for the user (via
umbral_sessions::login_user_id). - Sets the
Set-Cookieheader on the response (HttpOnly,Secure,SameSite=Lax,Max-Age=14 daysby default). - Updates
auth_user.last_logintoUtc::now()(best-effort).
It returns the raw session token so tests or instrumentation can read it. Production code ignores the return value.
login is the convenience form. To preserve an anonymous session's
data (flash messages, cart) across login, pass the request headers
too: umbral_auth::login_with_request(&request_headers, response.headers_mut(), &user).
Logging out
async fn logout(headers: HeaderMap) -> Response { let mut response = Redirect::to("/").into_response(); umbral_sessions::logout(&headers, response.headers_mut()) .await .ok(); // safe to call without a session response}logout(request_headers, response_headers) mirrors the login shape:
- Reads the session cookie from the request.
- Destroys the session row (no-op if there's no cookie).
- Sets a
Set-Cookieheader withMax-Age=0so the browser drops the cookie immediately.
request.user - User / OptionalUser extractors
axum extractors give you ergonomic access to the current user.
use umbral::prelude::*;use umbral::web::{Html, IntoResponse, StatusCode};use umbral_auth::{User, OptionalUser}; // Required - 401 if the request isn't authenticated. The handler// literally cannot run without a logged-in user.async fn dashboard(User(user): User) -> Html<String> { Html(format!("<h1>Welcome, {}</h1>", user.username))} // Optional - runs either way; you decide what to do.async fn home(OptionalUser(maybe): OptionalUser) -> Html<String> { match maybe { Some(user) => Html(format!("Hi, {}", user.username)), None => Html(r#"<a href="/login">Log in</a>"#.into()), }}Behind the scenes both extractors call
umbral_auth::current_user(&headers). The difference is the
rejection: User returns 401, OptionalUser is Infallible and
just wraps Option<AuthUser>.
User / OptionalUser are the AuthUser-specific extractors. For a
custom user model, reach for the generic LoggedIn<U> extractor (see
Auth gating) instead.
Why not a middleware that injects the user on every request?
umbral uses the extractor pattern instead. Saying User(user): User
in the handler signature makes the auth requirement visible at the
call site:
you can tell from the type whether a route is gated without
reading the middleware stack. The cost (a DB round-trip per request
that needs request.user) is the same either way.
Flash messages
umbral_sessions::Messages is an extractor that lets any handler
push flash messages into the session and any later handler drain
them. Backed by the session's data JSON column under a reserved
key (_umbral_messages).
use umbral::prelude::*;use umbral::web::{Form, IntoResponse, Redirect};use umbral_sessions::Messages; async fn save_post(messages: Messages, Form(form): Form<PostForm>) -> Response { Post::objects().create(/* ... */).await.unwrap(); messages.success("Post saved.").await; Redirect::to("/posts").into_response()} async fn list_posts(messages: Messages) -> Html<String> { let flash = messages.drain().await; // pulls all + clears // render the template with `messages = flash`; each carries // `.level` (`success` / `info` / `warning` / `error` / `debug`) // and `.text`. render("posts/list.html", &context!(messages => flash, /* ... */))}Methods on Messages:
| Method | Effect |
|---|---|
.add(level, text) | Append at the explicit level. |
.success(text) / .info(text) / .warning(text) / .error(text) / .debug(text) | Convenience wrappers for each level. |
.peek() | Read the queue without clearing. |
.drain() | Read + clear. The usual flow. |
.is_active() | True when there's a session backing this request. |
The five levels (Debug / Info / Success / Warning / Error)
serialise as their lowercase names so templates can do
class="alert alert-{{ msg.level }}" directly.
Anonymous flash messages just work. With SessionsPlugin::default()
auto-applied (the default), every browser has a session from its
first visit. Flash messages added by anonymous handlers survive the
redirect cycle naturally, including across login - the existing
session's data carries over to the post-login session when the
fixation defense regenerates the token.
The reserved key _umbral_messages inside session.data is owned
by this framework - don't write to it via set_data directly,
use Messages.
See also
- Users and passwords (umbral-auth) - the
AuthUsermodel, password hashing,authenticate. - Sessions plugin - what the session row looks like, the cookie attributes, the data column.