feat(sessions): identify online sessions (connected users)
identify online session by writing the `last_seen_at` information is stored in a map and flush each 30s to prevent performance impact on pgsql
This commit is contained in:
@@ -14,6 +14,8 @@
|
||||
//! separate batch fetch (extra round-trip). Frontend cross-references
|
||||
//! `user_id` against its cached user list.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
@@ -21,6 +23,20 @@ use uuid::Uuid;
|
||||
|
||||
use crate::domain::entities::session::{Session, SessionOrigin};
|
||||
|
||||
/// The "recently seen" threshold that turns a session's
|
||||
/// `last_seen_at` into a green-dot "Online" badge on the admin
|
||||
/// sessions panel — AND the same window that drives the
|
||||
/// `oxicloud_sessions_online[_users]` Prometheus gauges (see
|
||||
/// `src/infrastructure/services/session_liveness_gauges.rs`).
|
||||
/// The two MUST agree so the dashboard's per-row badge count
|
||||
/// matches the gauge's aggregate — one source of truth here.
|
||||
///
|
||||
/// 5 min feels responsive without over-fluctuating with
|
||||
/// tab-open-then-close blips. Deliberately hardcoded, not an
|
||||
/// env var — see `docs/plan/sessions.md` §"Config surface" for
|
||||
/// the reasoning.
|
||||
pub const ONLINE_WINDOW: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// Authenticated-caller context — the caller's identity + session-
|
||||
/// bound signals a service method might key off. Constructed at the
|
||||
/// handler boundary from `AuthUser` and passed through unchanged;
|
||||
@@ -53,6 +69,17 @@ pub struct SessionSummaryDto {
|
||||
pub user_id: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
/// Wall-clock time this session was last observed serving an
|
||||
/// authenticated request. Moved forward per request by the
|
||||
/// in-process [`LastSeenTracker`](crate::infrastructure::services::last_seen_tracker)
|
||||
/// via a batched UPDATE every 30 s — so this value trails the
|
||||
/// true "last seen" by at most one flush interval on a running
|
||||
/// server. On DB read it always converges after a graceful
|
||||
/// shutdown flush. Distinct from `created_at`: that only moves
|
||||
/// on session rotation (silent refresh), so its resolution is
|
||||
/// capped at the access-token TTL. The admin table renders a
|
||||
/// "last seen X ago" column off this field.
|
||||
pub last_seen_at: DateTime<Utc>,
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
/// `true` iff the session is DPoP-bound. Rendered as a lock icon
|
||||
@@ -68,7 +95,24 @@ pub struct SessionSummaryDto {
|
||||
pub is_revoked: bool,
|
||||
/// Whether this row is currently usable — `!revoked && expires_at > now()`.
|
||||
/// Kept server-side so the SPA doesn't drift if the browser clock is off.
|
||||
/// **Distinct from [`is_online`](Self::is_online)** — this is a
|
||||
/// *lifecycle* signal (row still has authority), that one is a
|
||||
/// *presence* signal (a request landed on it lately).
|
||||
pub is_active: bool,
|
||||
/// Whether the session was actually observed serving a request in the
|
||||
/// last [`ONLINE_WINDOW`] (5 min). Presence signal, orthogonal to
|
||||
/// [`is_active`](Self::is_active): a session may be active-and-online
|
||||
/// (green dot in the admin table), active-and-idle (no dot, "last
|
||||
/// seen 12 min ago"), or non-active-and-offline (expired / revoked
|
||||
/// rows are never online). Derived server-side against
|
||||
/// [`ONLINE_WINDOW`] so the row-level badge stays consistent with
|
||||
/// the `oxicloud_sessions_online[_users]` Prometheus aggregates.
|
||||
///
|
||||
/// Guaranteed `false` for revoked / expired rows — those short-
|
||||
/// circuit before the recency check so a revoked row that happened
|
||||
/// to receive a request in its final second before revocation
|
||||
/// doesn't confusingly render "Online" post-revocation.
|
||||
pub is_online: bool,
|
||||
// NOTE: no `oidc_sid` / `oidc_sid_prefix` field. The IdP-emitted
|
||||
// sid identifies the row's upstream session and stays server-side
|
||||
// (used by Back-Channel Logout matching). Exposing even a prefix
|
||||
@@ -104,23 +148,40 @@ impl SessionSummaryDto {
|
||||
pub fn from_session(s: Session, caller_jkt: Option<&str>) -> Self {
|
||||
let is_revoked = s.is_revoked();
|
||||
let is_expired = s.is_expired();
|
||||
let is_active = !is_revoked && !is_expired;
|
||||
let jkt = s.dpop_jkt().map(|s| s.to_owned());
|
||||
let dpop_jkt_prefix = jkt.as_ref().map(|t| t.chars().take(8).collect::<String>());
|
||||
let is_current = match (jkt.as_deref(), caller_jkt) {
|
||||
(Some(row), Some(caller)) => row == caller,
|
||||
_ => false,
|
||||
};
|
||||
// Presence check gated on lifecycle — a revoked or expired
|
||||
// row's `last_seen_at` may still be fresh (the last request
|
||||
// that arrived just before revocation), but calling it
|
||||
// "Online" post-revocation would confuse an admin reading
|
||||
// the panel. Short-circuit on !is_active.
|
||||
let online_cutoff = match chrono::Duration::from_std(ONLINE_WINDOW) {
|
||||
Ok(d) => Utc::now() - d,
|
||||
// Cast can only fail on a Duration too large for i64
|
||||
// milliseconds; not reachable with our 5 min constant.
|
||||
// Fall back to "never online" rather than panic — a
|
||||
// wrong badge is fixable, a request-path panic is not.
|
||||
Err(_) => DateTime::<Utc>::MAX_UTC,
|
||||
};
|
||||
let is_online = is_active && s.last_seen_at() > online_cutoff;
|
||||
Self {
|
||||
id: s.id(),
|
||||
user_id: s.user_id(),
|
||||
created_at: s.created_at(),
|
||||
expires_at: s.expires_at(),
|
||||
last_seen_at: s.last_seen_at(),
|
||||
ip_address: s.ip_address().map(str::to_owned),
|
||||
user_agent: s.user_agent().map(str::to_owned),
|
||||
is_bound: jkt.is_some(),
|
||||
dpop_jkt_prefix,
|
||||
is_revoked,
|
||||
is_active: !is_revoked && !is_expired,
|
||||
is_active,
|
||||
is_online,
|
||||
origin: s.origin(),
|
||||
is_current,
|
||||
}
|
||||
@@ -166,6 +227,7 @@ mod tests {
|
||||
Some(sid.to_string()),
|
||||
None,
|
||||
crate::domain::entities::session::SessionOrigin::Oidc,
|
||||
Utc::now(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -241,6 +303,121 @@ mod tests {
|
||||
assert!(dto.is_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dto_exposes_last_seen_at() {
|
||||
// Regression: the admin table renders "last seen X ago"
|
||||
// straight off this field, and clients that build
|
||||
// dashboards off the session API rely on it too. Guards
|
||||
// against a struct field being removed / renamed silently.
|
||||
let s = base(false, None);
|
||||
let expected = s.last_seen_at();
|
||||
let dto = SessionSummaryDto::from(s);
|
||||
assert_eq!(dto.last_seen_at, expected);
|
||||
let json = serde_json::to_string(&dto).unwrap();
|
||||
assert!(
|
||||
json.contains("\"last_seen_at\""),
|
||||
"wire shape must include `last_seen_at`: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A freshly-minted, unbound, unrevoked session ships with
|
||||
/// `last_seen_at = Utc::now()` from `Session::new`, so it
|
||||
/// MUST render as online. This is the green-dot happy path
|
||||
/// the admin panel keys off — regression here means the
|
||||
/// dashboard misses every currently-active session.
|
||||
#[test]
|
||||
fn dto_is_online_when_last_seen_is_fresh() {
|
||||
let dto = SessionSummaryDto::from(base(false, None));
|
||||
assert!(dto.is_online, "fresh session must be online: {dto:?}");
|
||||
assert!(dto.is_active);
|
||||
}
|
||||
|
||||
/// A session whose `last_seen_at` is older than the
|
||||
/// [`ONLINE_WINDOW`] MUST render as offline even when the
|
||||
/// row is otherwise Active — that's the whole point of the
|
||||
/// presence vs lifecycle split. Constructed via `from_raw`
|
||||
/// so we can stamp a stale timestamp deterministically.
|
||||
#[test]
|
||||
fn dto_is_not_online_when_last_seen_is_stale() {
|
||||
let stale = Utc::now() - chrono::Duration::hours(1);
|
||||
let s = Session::from_raw(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
"rt".to_string(),
|
||||
Utc::now() + Duration::days(30),
|
||||
None,
|
||||
None,
|
||||
stale,
|
||||
false,
|
||||
Uuid::new_v4(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
SessionOrigin::Password,
|
||||
stale,
|
||||
);
|
||||
let dto = SessionSummaryDto::from(s);
|
||||
assert!(!dto.is_online, "1h-idle session must not be online");
|
||||
assert!(dto.is_active, "stale-but-alive session stays active");
|
||||
}
|
||||
|
||||
/// Anti-confusion guard: a revoked row whose `last_seen_at`
|
||||
/// happens to be fresh (the last request that landed just
|
||||
/// before revocation) must NOT surface as "Online" — an admin
|
||||
/// reading the panel post-revocation expects the green dot
|
||||
/// gone. `is_online` short-circuits on `!is_active`.
|
||||
#[test]
|
||||
fn dto_is_not_online_when_revoked_even_if_fresh() {
|
||||
let dto = SessionSummaryDto::from(base(true, None));
|
||||
assert!(dto.is_revoked);
|
||||
assert!(!dto.is_active);
|
||||
assert!(
|
||||
!dto.is_online,
|
||||
"revoked-but-fresh row must never render as online",
|
||||
);
|
||||
}
|
||||
|
||||
/// Same anti-confusion guard for expiry: a session that's
|
||||
/// past `expires_at` but whose last request landed in the
|
||||
/// last 5 min must not surface as online.
|
||||
#[test]
|
||||
fn dto_is_not_online_when_expired_even_if_fresh() {
|
||||
let past = Utc::now() - Duration::days(1);
|
||||
let s = Session::from_raw(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
"rt".to_string(),
|
||||
past, // expires_at in the past
|
||||
None,
|
||||
None,
|
||||
past,
|
||||
false,
|
||||
Uuid::new_v4(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
SessionOrigin::Password,
|
||||
Utc::now(), // last_seen_at fresh
|
||||
);
|
||||
let dto = SessionSummaryDto::from(s);
|
||||
assert!(!dto.is_active, "expired session is not active");
|
||||
assert!(
|
||||
!dto.is_online,
|
||||
"expired-but-fresh row must never render as online",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_session_has_last_seen_equal_to_created_at() {
|
||||
// The DB default is `NOW()` and `Session::new` mirrors
|
||||
// that with `Utc::now()` for BOTH columns — so a
|
||||
// freshly-minted session immediately counts as "recently
|
||||
// active" for the liveness gauges rather than showing up
|
||||
// as long-idle for the first flush interval.
|
||||
let s = base(false, None);
|
||||
assert_eq!(s.created_at(), s.last_seen_at());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_raw_expired_session_is_not_active() {
|
||||
let past = Utc::now() - Duration::days(1);
|
||||
@@ -258,6 +435,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
crate::domain::entities::session::SessionOrigin::Unknown,
|
||||
past,
|
||||
);
|
||||
let dto = SessionSummaryDto::from(s);
|
||||
assert!(!dto.is_active);
|
||||
|
||||
@@ -63,6 +63,14 @@ pub struct TokenClaims {
|
||||
/// The DPoP middleware reads it from the already-validated token
|
||||
/// (no DB round trip) to enforce "bound session → proof required".
|
||||
pub dpop_jkt: Option<String>,
|
||||
/// Session identifier — the `auth.sessions.id` this access token
|
||||
/// was minted for. Read by the auth middleware to stamp
|
||||
/// per-session liveness via [`LastSeenTracker`](crate::infrastructure::services::last_seen_tracker)
|
||||
/// with no DB round trip. `None` for tokens minted by builds
|
||||
/// that predate the `sid` claim (backward compat during rollout;
|
||||
/// harmless — the missing sid just means no stamp fires, and
|
||||
/// the token still authenticates normally).
|
||||
pub sid: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Port for JWT token operations.
|
||||
@@ -81,6 +89,7 @@ pub trait TokenServicePort: Send + Sync + 'static {
|
||||
fn generate_access_token(
|
||||
&self,
|
||||
user: &User,
|
||||
session_id: Option<Uuid>,
|
||||
dpop_jkt: Option<&str>,
|
||||
) -> Result<String, DomainError>;
|
||||
|
||||
|
||||
@@ -1269,21 +1269,17 @@ impl AuthApplicationService {
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Generate tokens using the injected token service. The
|
||||
// access token carries the `cnf.jkt` binding when present,
|
||||
// so the DPoP middleware can enforce "bound → proof required"
|
||||
// straight from the already-validated JWT — no session-row
|
||||
// lookup on the hot path.
|
||||
let access_token = self
|
||||
.token_service
|
||||
.generate_access_token(&user, validated_jkt.as_deref())?;
|
||||
|
||||
let refresh_token = self.token_service.generate_refresh_token();
|
||||
|
||||
// Construct the session FIRST so its id is available to the
|
||||
// token mint below — the `sid` claim lets the auth middleware
|
||||
// stamp per-session liveness with no DB round trip. Order
|
||||
// was reversed as part of the `last_seen_at` wiring
|
||||
// (`docs/plan/sessions.md`).
|
||||
//
|
||||
// Save session — new login starts a new token family. DPoP
|
||||
// binding is set at INSERT time and immutable thereafter (see
|
||||
// `docs/plan/dpop.md` — a mutable bind would let an attacker
|
||||
// downgrade a bound session by re-binding to their own key).
|
||||
let refresh_token = self.token_service.generate_refresh_token();
|
||||
let mut session = Session::new(
|
||||
user.id(),
|
||||
refresh_token.clone(),
|
||||
@@ -1293,6 +1289,18 @@ impl AuthApplicationService {
|
||||
Uuid::new_v4(),
|
||||
origin,
|
||||
);
|
||||
|
||||
// Generate tokens using the injected token service. The
|
||||
// access token carries the `cnf.jkt` binding when present,
|
||||
// so the DPoP middleware can enforce "bound → proof required"
|
||||
// straight from the already-validated JWT — no session-row
|
||||
// lookup on the hot path. `sid` correlates the token to the
|
||||
// session row constructed just above.
|
||||
let access_token = self.token_service.generate_access_token(
|
||||
&user,
|
||||
Some(session.id()),
|
||||
validated_jkt.as_deref(),
|
||||
)?;
|
||||
if let Some(jkt) = validated_jkt {
|
||||
// Success-path audit — records the bind so operators can
|
||||
// correlate a session_id in the panel with the exact moment
|
||||
@@ -1562,8 +1570,9 @@ impl AuthApplicationService {
|
||||
// thread `dpop_jkt` into a GET body. Session is minted
|
||||
// unbound; the SPA calls `POST /api/auth/dpop/bind`
|
||||
// post-redirect to bind it (see Gate 3). Token accordingly
|
||||
// ships without `cnf.jkt`.
|
||||
let access_token = self.token_service.generate_access_token(&user, None)?;
|
||||
// ships without `cnf.jkt`. Session constructed first so
|
||||
// its id can feed the token's `sid` claim — see the login
|
||||
// path above for the rationale.
|
||||
let refresh_token = self.token_service.generate_refresh_token();
|
||||
let session = Session::new(
|
||||
user.id(),
|
||||
@@ -1574,6 +1583,9 @@ impl AuthApplicationService {
|
||||
Uuid::new_v4(),
|
||||
crate::domain::entities::session::SessionOrigin::MagicLink,
|
||||
);
|
||||
let access_token =
|
||||
self.token_service
|
||||
.generate_access_token(&user, Some(session.id()), None)?;
|
||||
self.session_storage.create_session(session).await?;
|
||||
|
||||
tracing::info!(
|
||||
@@ -1715,16 +1727,12 @@ impl AuthApplicationService {
|
||||
));
|
||||
}
|
||||
|
||||
// Generate new tokens. Inherit the DPoP binding from the
|
||||
// parent session so the refreshed access token carries the
|
||||
// same `cnf.jkt` — otherwise every refresh would silently
|
||||
// downgrade to unbound and the next request would 401 under
|
||||
// Gate 9 enforcement (see Gate 7).
|
||||
let access_token = self
|
||||
.token_service
|
||||
.generate_access_token(&user, session.dpop_jkt())?;
|
||||
let new_refresh_token = self.token_service.generate_refresh_token();
|
||||
|
||||
// Rotate the session first so the new row's id is available
|
||||
// to the token mint below — the `sid` claim tracks the
|
||||
// freshly-inserted row, not the revoked parent. Order
|
||||
// reversed as part of the `last_seen_at` wiring
|
||||
// (`docs/plan/sessions.md`).
|
||||
//
|
||||
// New session inherits the family_id so reuse of any ancestor triggers
|
||||
// full-family revocation. Revoking the old session and inserting the
|
||||
// new one happen in ONE transaction (`rotate_session`) — this path
|
||||
@@ -1738,6 +1746,7 @@ impl AuthApplicationService {
|
||||
// refresh silently downgrade the session to unbound, and every
|
||||
// subsequent request would fail DPoP verification once required
|
||||
// mode enforces per-session binding.
|
||||
let new_refresh_token = self.token_service.generate_refresh_token();
|
||||
let mut new_session = Session::new(
|
||||
user.id(),
|
||||
new_refresh_token.clone(),
|
||||
@@ -1770,6 +1779,16 @@ impl AuthApplicationService {
|
||||
new_session = new_session.with_oidc_sid(sid.to_string());
|
||||
}
|
||||
|
||||
// Mint the access token AFTER the new session is fully
|
||||
// configured — the `sid` claim points at the new row's id,
|
||||
// and `cnf.jkt` inherits from the parent so DPoP proof
|
||||
// enforcement (Gate 9) still holds across the rotation.
|
||||
let access_token = self.token_service.generate_access_token(
|
||||
&user,
|
||||
Some(new_session.id()),
|
||||
session.dpop_jkt(),
|
||||
)?;
|
||||
|
||||
self.session_storage
|
||||
.rotate_session(session.id(), new_session)
|
||||
.await?;
|
||||
@@ -4608,8 +4627,8 @@ impl AuthApplicationService {
|
||||
// through the browser's redirect chain. Session is minted
|
||||
// unbound; the SPA calls `POST /api/auth/dpop/bind` post-
|
||||
// redirect to bind it (see Gate 3). Token accordingly ships
|
||||
// without `cnf.jkt`.
|
||||
let access_token = self.token_service.generate_access_token(&user, None)?;
|
||||
// without `cnf.jkt`. Session constructed first so its id
|
||||
// feeds the token's `sid` claim.
|
||||
let refresh_token = self.token_service.generate_refresh_token();
|
||||
|
||||
let mut session = Session::new(
|
||||
@@ -4630,6 +4649,11 @@ impl AuthApplicationService {
|
||||
if let Some(sid) = claims.sid.as_ref() {
|
||||
session = session.with_oidc_sid(sid.clone());
|
||||
}
|
||||
// Mint AFTER session is fully configured so `sid` claim
|
||||
// aligns with the row about to be inserted.
|
||||
let access_token =
|
||||
self.token_service
|
||||
.generate_access_token(&user, Some(session.id()), None)?;
|
||||
self.session_storage.create_session(session).await?;
|
||||
|
||||
let force_password_change = self.read_force_password_change(user.id()).await;
|
||||
|
||||
@@ -180,10 +180,12 @@ impl DeviceAuthService {
|
||||
// clients that don't run WebCrypto — always unbound (`None`
|
||||
// for the `dpop_jkt` param), which the DPoP middleware exempts
|
||||
// from proof requirements. See `docs/plan/dpop.md` Gate 9.
|
||||
let access_token = self.token_service.generate_access_token(&user, None)?;
|
||||
//
|
||||
// Session constructed first so its id feeds the token's
|
||||
// `sid` claim — the auth middleware uses this to stamp
|
||||
// per-session liveness without a DB round trip
|
||||
// (`docs/plan/sessions.md`).
|
||||
let refresh_token = self.token_service.generate_refresh_token();
|
||||
|
||||
// Persist refresh token as a session
|
||||
let session = Session::new(
|
||||
user_id,
|
||||
refresh_token.clone(),
|
||||
@@ -193,6 +195,9 @@ impl DeviceAuthService {
|
||||
Uuid::new_v4(),
|
||||
crate::domain::entities::session::SessionOrigin::Device,
|
||||
);
|
||||
let access_token =
|
||||
self.token_service
|
||||
.generate_access_token(&user, Some(session.id()), None)?;
|
||||
self.session_storage.create_session(session).await?;
|
||||
|
||||
// Store tokens on the device code entity
|
||||
|
||||
Reference in New Issue
Block a user