umbral_auth/session_user.rs
1//! `AuthUser`-aware session helpers — moved from umbral-sessions so
2//! sessions can stay free of any user-model dependency.
3//!
4//! The split mirrors the dep arrow: `umbral-auth` depends on
5//! `umbral-sessions` (it needs cookie + session-table primitives),
6//! `umbral-sessions` does not depend on `umbral-auth` (it knows
7//! nothing about users). All the AuthUser hydration happens here.
8//!
9//! ## What this module owns
10//!
11//! - [`current_user`] — read the cookie, hydrate the [`AuthUser`].
12//! - [`login`] / [`login_with_request`] — the one-call shape
13//! for credential check + session creation + cookie set +
14//! `last_login` bump.
15//! - [`logout`] — re-exported convenience; same as
16//! `umbral_sessions::logout` plus a forwarding doc-comment.
17//! - [`SessionAuthentication`] — the `umbral-rest` `Authentication`
18//! impl that produces an `Identity` for the permission layer
19//! (was in `umbral-sessions`; needs AuthUser to populate
20//! `is_staff`).
21//! - [`User`] / [`OptionalUser`] — axum extractors that pull
22//! `AuthUser` from the request.
23//! - [`user_context_layer`] — middleware that injects the current
24//! user into `umbral::templates::CURRENT_USER` so HTML templates
25//! can write `{% if user.is_authenticated %}` uniformly.
26//!
27//! ## Custom user models
28//!
29//! Everything in here is hard-bound to [`AuthUser`]. Apps using a
30//! custom [`UserModel`] roll their own helpers — the building
31//! blocks are all `pub`:
32//!
33//! - `umbral_sessions::current_user_id_str(&headers)` → user PK as
34//! a string (already user-agnostic).
35//! - Their own user lookup against that PK.
36//! - Their own `Identity` builder.
37//!
38//! [`UserModel`]: crate::UserModel
39
40use crate::{AuthUser, auth_user};
41use async_trait::async_trait;
42use axum_core::extract::FromRequestParts;
43use http::StatusCode;
44use http::request::Parts;
45use umbral::web::HeaderMap;
46use umbral::auth::{Authentication, Identity};
47use umbral_sessions::SessionError;
48
49// =========================================================================
50// current_user — the AuthUser-flavored wrapper around
51// umbral_sessions::current_session.
52// =========================================================================
53
54/// Read the request's session cookie, look up the session row, then
55/// hydrate the [`AuthUser`] it points at. Returns `None` for any
56/// of: no cookie, expired session, anonymous session
57/// (`user_id IS NULL`), parse failure on a non-i64 user_id, missing
58/// user row, or inactive user.
59///
60/// One DB read (session row) + one DB read (user row). The
61/// `is_active` predicate is part of the user query, so a deactivated
62/// account silently looks anonymous from this helper's perspective
63/// without an explicit second filter at the call site.
64pub async fn current_user(headers: &HeaderMap) -> Result<Option<AuthUser>, SessionError> {
65 let Some(user_id_str) = umbral_sessions::current_user_id_str(headers).await? else {
66 return Ok(None);
67 };
68 // Session.user_id is text (gap #59) — parse back to AuthUser's
69 // i64 PK. A non-parseable value means the session was written
70 // by a different UserModel impl; from AuthUser's perspective
71 // that's anonymous.
72 let Ok(user_id) = user_id_str.parse::<i64>() else {
73 return Ok(None);
74 };
75 let user: Option<AuthUser> = AuthUser::objects()
76 .filter(auth_user::ID.eq(user_id) & auth_user::IS_ACTIVE.eq(true))
77 .first()
78 .await?;
79 Ok(user)
80}
81
82// =========================================================================
83// login / login_with_request — credential check ran outside, we just
84// mint the session + cookie + bump last_login.
85// =========================================================================
86
87/// Convenience: [`login_with_request`] with an empty request
88/// HeaderMap. Use when the handler doesn't already have a
89/// `HeaderMap` extractor and you're not worried about preserving
90/// an anonymous session's `data` (flash messages, cart) across the
91/// login.
92pub async fn login(
93 response_headers: &mut HeaderMap,
94 user: &AuthUser,
95) -> Result<String, SessionError> {
96 login_with_request(&HeaderMap::new(), response_headers, user).await
97}
98
99/// Mint an authenticated session for `user`, rotate the cookie, and
100/// bump `auth_user.last_login`. The session-fixation defense fires
101/// inside `umbral_sessions::login_user_id`: any anonymous session
102/// the request carried is destroyed before the new authenticated
103/// row is written.
104///
105/// `last_login` is a best-effort update: a failure logs a warning
106/// but doesn't invalidate the login (the session was created and
107/// the cookie was set, so the user is in).
108pub async fn login_with_request(
109 request_headers: &HeaderMap,
110 response_headers: &mut HeaderMap,
111 user: &AuthUser,
112) -> Result<String, SessionError> {
113 let token =
114 umbral_sessions::login_user_id(request_headers, response_headers, Some(user.id.to_string()))
115 .await?;
116
117 let mut patch = serde_json::Map::new();
118 patch.insert(
119 "last_login".to_string(),
120 serde_json::to_value(chrono::Utc::now()).unwrap_or(serde_json::Value::Null),
121 );
122 if let Err(e) = AuthUser::objects()
123 .filter(auth_user::ID.eq(user.id))
124 .update_values(patch)
125 .await
126 {
127 tracing::warn!(
128 error = ?e,
129 user_id = user.id,
130 "umbral-auth::login: failed to update last_login (session still active)",
131 );
132 }
133 Ok(token)
134}
135
136// =========================================================================
137// SessionAuthentication — produce an `Identity` for the REST
138// permission layer.
139// =========================================================================
140
141/// The session-cookie authenticator for `umbral-rest`. Reads the
142/// cookie, hydrates the [`AuthUser`], turns it into an [`Identity`]
143/// with `is_staff` set. Same shape `current_user` produces, packaged
144/// for `RestPlugin::authenticate`.
145///
146/// Was in `umbral-sessions` before the de-coupling; now here so it
147/// can name `AuthUser`.
148#[derive(Debug, Default, Clone, Copy)]
149pub struct SessionAuthentication;
150
151impl SessionAuthentication {
152 /// Convenience constructor identical to `Default::default()`.
153 pub fn new() -> Self {
154 Self
155 }
156}
157
158#[async_trait]
159impl Authentication for SessionAuthentication {
160 async fn authenticate(&self, headers: &HeaderMap) -> Option<Identity> {
161 let user = current_user(headers).await.ok().flatten()?;
162 // `user.id_string()` is the UserModel-level stringifier —
163 // it stays correct when an app swaps AuthUser for a custom
164 // user model with a non-i64 PK. `Identity::user_id` is
165 // String regardless because Identity must be uniform across
166 // user models.
167 Some(
168 Identity::user(crate::UserModel::id_string(&user))
169 .with_staff(user.is_staff)
170 .with_superuser(user.is_superuser)
171 .with_extra("auth", serde_json::json!("session")),
172 )
173 }
174
175 fn security_scheme(&self) -> Option<(String, serde_json::Value)> {
176 // Standard "session cookie" scheme. The actual cookie name
177 // (`umbral_session`) is documented in the description so
178 // Swagger UI users know what they're authorising with.
179 Some((
180 "SessionAuth".to_string(),
181 serde_json::json!({
182 "type": "apiKey",
183 "in": "cookie",
184 "name": "umbral_session",
185 "description": "umbral session cookie. Set by `POST /api/auth/login`; cleared by `/logout`."
186 }),
187 ))
188 }
189}
190
191// =========================================================================
192// User / OptionalUser axum extractors. Same shapes that used to live
193// in umbral-sessions::extractors.
194// =========================================================================
195
196/// Required-user extractor. 401 on anonymous requests.
197///
198/// ```ignore
199/// async fn dashboard(User(user): User) -> Html<String> {
200/// Html(format!("Welcome, {}!", user.username))
201/// }
202/// ```
203#[derive(Debug, Clone)]
204pub struct User(pub AuthUser);
205
206/// Optional-user extractor. Anonymous requests get `None`.
207///
208/// ```ignore
209/// async fn home(OptionalUser(maybe): OptionalUser) -> Html<String> {
210/// match maybe {
211/// Some(u) => Html(format!("Hi, {}", u.username)),
212/// None => Html("<a href=\"/login\">Log in</a>".into()),
213/// }
214/// }
215/// ```
216#[derive(Debug, Clone)]
217pub struct OptionalUser(pub Option<AuthUser>);
218
219impl<S> FromRequestParts<S> for User
220where
221 S: Send + Sync,
222{
223 type Rejection = (StatusCode, &'static str);
224
225 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
226 match current_user(&parts.headers).await.ok().flatten() {
227 Some(u) => Ok(User(u)),
228 None => Err((StatusCode::UNAUTHORIZED, "authentication required")),
229 }
230 }
231}
232
233impl<S> FromRequestParts<S> for OptionalUser
234where
235 S: Send + Sync,
236{
237 type Rejection = std::convert::Infallible;
238
239 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
240 Ok(OptionalUser(
241 current_user(&parts.headers).await.ok().flatten(),
242 ))
243 }
244}
245
246// =========================================================================
247// Template-injection middleware. Stash the current user under the
248// `umbral::templates::CURRENT_USER` task-local so HTML renders can
249// pick it up as `{{ user }}`.
250// =========================================================================
251
252/// Install a per-request lazy resolver on the
253/// [`umbral::templates::CURRENT_USER_LAZY`] channel.
254///
255/// The resolver clones the request headers and runs **at most once**,
256/// only when a template actually accesses `{{ user }}`. Requests that
257/// never render a template (JSON/API responses) pay zero DB reads.
258/// When the resolver does run it performs the session + user lookup
259/// and memoizes the result for the rest of the request.
260///
261/// Opt in via [`crate::AuthPlugin::with_user_in_templates`] when the
262/// app is HTML-heavy; leave off for REST-only services.
263pub async fn user_context_layer(
264 req: axum::extract::Request,
265 next: axum::middleware::Next,
266) -> axum::response::Response {
267 // Install a LAZY resolver instead of resolving eagerly. The closure runs
268 // (at most once) only if a template actually reads `user`; a JSON/API
269 // response that never renders the template pays nothing.
270 let headers = req.headers().clone();
271 let lazy = umbral::templates::LazyUser::new(move || {
272 let headers = headers.clone();
273 async move {
274 match current_user(&headers).await {
275 Ok(Some(u)) => serialize_authenticated_with_relations(&u).await,
276 _ => anonymous_user_value(),
277 }
278 }
279 });
280 umbral::templates::with_current_user_lazy(lazy, next.run(req)).await
281}
282
283/// Depth cap for the recursive relation expansion in
284/// [`serialize_authenticated_with_relations`]. Closes gap2 #14:
285/// templates can write `user.customer.loyalty_points` and get the
286/// resolved value without the handler having to declare the prefetch.
287///
288/// Why 2: covers the common case `user.<one_to_one>.<scalar>` (1 hop
289/// to load the child, scalars are free) AND `user.<one_to_one>.<fk>`
290/// (2 hops if the template wants to walk back into another object).
291/// Beyond 2 the query budget grows with the graph fan-out and the
292/// "templates pay for every relation, every request" trade-off
293/// stops being honest.
294const USER_RELATION_DEPTH: usize = 2;
295
296async fn serialize_authenticated_with_relations(user: &AuthUser) -> umbral::templates::Value {
297 let mut json = match serde_json::to_value(user) {
298 Ok(serde_json::Value::Object(map)) => map,
299 _ => serde_json::Map::new(),
300 };
301 json.insert(
302 "is_authenticated".to_string(),
303 serde_json::Value::Bool(true),
304 );
305
306 // gap2 #14: recursively expand reverse-O2O and forward-FK
307 // relations on the serialized user, up to `USER_RELATION_DEPTH`
308 // hops, with `(table, pk)` cycle detection so
309 // `user.customer.user.customer...` terminates.
310 //
311 // PK lift Pass C: the visited set keys on `(table_name,
312 // pk_json_key(value))` so non-i64 user PKs (UUID-keyed
313 // AuthUser variants, codename-keyed permissions, etc.) ride
314 // through the same cycle detector. The pre-fix shape was
315 // `HashSet<(String, i64)>` which silently coerced everything
316 // to i64 and broke for any UserModel impl with a non-i64 PK.
317 //
318 // The auth_user table must exist in the registry (AuthPlugin
319 // registers it during App::build); if for some reason it's
320 // missing we silently fall back to the un-expanded user JSON
321 // rather than failing the request.
322 let registered = umbral::migrate::registered_models();
323 if let Some(meta) = registered.iter().find(|m| m.table == "auth_user") {
324 let mut visited: std::collections::HashSet<(String, String)> =
325 std::collections::HashSet::new();
326 let seed_pk = serde_json::Value::Number(user.id.into());
327 visited.insert(("auth_user".to_string(), pk_json_key(&seed_pk)));
328 expand_relations(
329 meta,
330 ®istered,
331 &mut json,
332 USER_RELATION_DEPTH,
333 &mut visited,
334 )
335 .await;
336 }
337
338 umbral::templates::Value::from_serialize(serde_json::Value::Object(json))
339}
340
341/// Recursive depth-bounded expansion of a row's FK relations
342/// (gap2 #14). Mutates `row` in place to:
343///
344/// - Replace every forward-FK integer id with the resolved target
345/// row (when known to the model registry).
346/// - Inject every reverse-OneToOne candidate (child models with a
347/// UNIQUE FK pointing at `meta`) under the child table's name as
348/// the key — so `Customer { user: ForeignKey<AuthUser> (unique) }`
349/// surfaces as `user.customer` on the parent.
350///
351/// `visited` carries `(table, pk)` pairs already loaded in this
352/// expansion. New rows are checked against it before recursion and
353/// inserted before descending, so any cycle in the FK graph
354/// terminates at the first revisit.
355///
356/// One query per loaded relation per request — the middleware's
357/// query budget grows by `count(relations within depth)`, not by
358/// the fan-out of subsequent template renders. Sparse relation
359/// graphs (the common case) add 1-3 queries; pathological graphs
360/// hit the depth cap and stop.
361fn expand_relations<'a>(
362 meta: &'a umbral::migrate::ModelMeta,
363 registered: &'a [umbral::migrate::ModelMeta],
364 row: &'a mut serde_json::Map<String, serde_json::Value>,
365 depth: usize,
366 visited: &'a mut std::collections::HashSet<(String, String)>,
367) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
368 Box::pin(async move {
369 if depth == 0 {
370 return;
371 }
372
373 // -- Forward FKs: replace integer / string / UUID ids with
374 // the full target row. Mirrors the dynamic
375 // `select_related_dyn` semantics (gap2 #15) but driven by
376 // the registry walk here so the user middleware doesn't
377 // need to know which columns to expand ahead of time.
378 //
379 // PK lift Pass C: read the FK value as `serde_json::Value`
380 // (not i64) so non-integer-PK targets (codename-keyed
381 // permissions, UUID-keyed custom user models, etc.) flow
382 // through. The cycle key uses `pk_json_key` so a numeric
383 // 42 and a string "42" stay in different buckets.
384 let forward_fks: Vec<(String, String, serde_json::Value)> = meta
385 .fields
386 .iter()
387 .filter_map(|col| {
388 let target_table = col.fk_target.as_deref()?;
389 let fk_val = row.get(&col.name)?.clone();
390 if fk_val.is_null() {
391 return None;
392 }
393 Some((col.name.clone(), target_table.to_string(), fk_val))
394 })
395 .collect();
396 for (col_name, target_table, fk_val) in forward_fks {
397 let visit_key = (target_table.clone(), pk_json_key(&fk_val));
398 if visited.contains(&visit_key) {
399 continue;
400 }
401 let Some(target_meta) = registered.iter().find(|m| m.table == target_table) else {
402 continue;
403 };
404 let Some(target_pk) = target_meta.pk_column() else {
405 continue;
406 };
407 let fetched = umbral::orm::DynQuerySet::for_meta(target_meta)
408 .filter_eq_string(&target_pk.name, &json_value_to_pk_string(&fk_val))
409 .first_as_json()
410 .await;
411 let Ok(Some(mut target_row)) = fetched else {
412 continue;
413 };
414 visited.insert(visit_key);
415 expand_relations(target_meta, registered, &mut target_row, depth - 1, visited).await;
416 row.insert(col_name, serde_json::Value::Object(target_row));
417 }
418
419 // -- Reverse-O2O: child models with a UNIQUE FK to this
420 // table get injected under the child's table name. Naming
421 // convention uses the lower-case-model-name idiom
422 // (`Customer { user: FK<User> (unique) }` → `user.customer`).
423 let Some(parent_pk_col) = meta.pk_column() else {
424 return;
425 };
426 // PK lift Pass C: parent PK as `serde_json::Value`, not i64.
427 let parent_pk = match row.get(&parent_pk_col.name).cloned() {
428 Some(v) if !v.is_null() => v,
429 _ => return,
430 };
431 let candidates: Vec<(&umbral::migrate::ModelMeta, String)> = registered
432 .iter()
433 .filter_map(|child| {
434 // Need exactly one UNIQUE FK pointing at this
435 // table; ambiguous matches (e.g. `primary_user` +
436 // `backup_user` both UNIQUE FKs to auth_user) are
437 // skipped — there's no single right answer for
438 // which one becomes `user.customer`.
439 let mut matches = child
440 .fields
441 .iter()
442 .filter(|c| c.fk_target.as_deref() == Some(&meta.table) && c.unique);
443 let first = matches.next()?;
444 if matches.next().is_some() {
445 return None;
446 }
447 Some((child, first.name.clone()))
448 })
449 .collect();
450 for (child_meta, fk_col_name) in candidates {
451 // Don't clobber a same-named scalar on the parent — if
452 // a model genuinely names a column the same as its
453 // child table (rare), the existing column wins.
454 if row.contains_key(&child_meta.table) {
455 continue;
456 }
457 let fetched = umbral::orm::DynQuerySet::for_meta(child_meta)
458 .filter_eq_string(&fk_col_name, &json_value_to_pk_string(&parent_pk))
459 .first_as_json()
460 .await;
461 let Ok(Some(mut child_row)) = fetched else {
462 continue;
463 };
464 let Some(child_pk_col) = child_meta.pk_column() else {
465 continue;
466 };
467 // PK lift Pass C: child PK as `serde_json::Value`.
468 let Some(child_pk) = child_row.get(&child_pk_col.name).cloned() else {
469 continue;
470 };
471 if child_pk.is_null() {
472 continue;
473 }
474 let visit_key = (child_meta.table.clone(), pk_json_key(&child_pk));
475 if visited.contains(&visit_key) {
476 continue;
477 }
478 visited.insert(visit_key);
479 expand_relations(child_meta, registered, &mut child_row, depth - 1, visited).await;
480 row.insert(
481 child_meta.table.clone(),
482 serde_json::Value::Object(child_row),
483 );
484 }
485 })
486}
487
488/// PK lift Pass C: stable cycle-key for the `visited` HashSet in
489/// [`expand_relations`]. `serde_json::Value` isn't `Hash`, so we
490/// flatten to a namespaced `String` per shape. Mirrors the
491/// `pk_json_key` helper in `umbral-core::orm::dynamic` — kept local
492/// here to avoid widening `umbral-core`'s pub surface for one tiny
493/// helper. If a third call site needs the same namespacing, the
494/// two should converge into one canonical pub fn in the facade.
495fn pk_json_key(v: &serde_json::Value) -> String {
496 match v {
497 serde_json::Value::Number(n) => format!("n:{n}"),
498 serde_json::Value::String(s) => format!("s:{s}"),
499 other => format!("o:{other}"),
500 }
501}
502
503/// Render a PK JSON value as the string `DynQuerySet::filter_eq_string`
504/// expects to bind against. `filter_eq_string` already coerces per the
505/// column's `SqlType` so the right operand type lands on the wire —
506/// we just need to hand it the value's `Display` form.
507fn json_value_to_pk_string(v: &serde_json::Value) -> String {
508 match v {
509 serde_json::Value::Number(n) => n.to_string(),
510 serde_json::Value::String(s) => s.clone(),
511 other => other.to_string(),
512 }
513}
514
515fn anonymous_user_value() -> umbral::templates::Value {
516 let mut json = serde_json::Map::new();
517 json.insert(
518 "is_authenticated".to_string(),
519 serde_json::Value::Bool(false),
520 );
521 umbral::templates::Value::from_serialize(serde_json::Value::Object(json))
522}
523
524// =========================================================================
525// logout — pure re-export. Sessions still owns the call shape (no
526// user model involved), but we re-export from umbral-auth too so the
527// import surface stays uniform across login + logout.
528// =========================================================================
529
530/// Pure forwarding alias for [`umbral_sessions::logout`]. Re-exported
531/// so handlers that import `umbral_auth::login` also reach for
532/// `umbral_auth::logout` without flipping crates. Sessions still owns
533/// the implementation; it's user-agnostic.
534pub use umbral_sessions::logout;