feat(session): handle sessions for admin
This commit is contained in:
@@ -20,6 +20,7 @@ pub mod playlist_dto;
|
||||
pub mod plugin_dto;
|
||||
pub mod recent_dto;
|
||||
pub mod search_dto;
|
||||
pub mod session_dto;
|
||||
pub mod settings_dto;
|
||||
pub mod share_dto;
|
||||
pub mod trash_dto;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! DTOs for the admin sessions panel.
|
||||
//!
|
||||
//! [`SessionSummaryDto`] is the wire shape returned by
|
||||
//! `GET /api/admin/sessions`. It's deliberately narrower than the
|
||||
//! `Session` domain entity — the `refresh_token` and any OIDC
|
||||
//! ID-token payload are **never** serialized; the raw DPoP thumbprint
|
||||
//! is truncated to an 8-char prefix so an admin viewing another
|
||||
//! user's sessions cannot exfiltrate the full binding fingerprint.
|
||||
//!
|
||||
//! Enrichment (username/email lookup for each `user_id`) is
|
||||
//! intentionally deferred to the SPA — it already caches the admin
|
||||
//! user list, and doing the JOIN server-side would either force a
|
||||
//! per-request JOIN (extra work most operators don't need) or a
|
||||
//! separate batch fetch (extra round-trip). Frontend cross-references
|
||||
//! `user_id` against its cached user list.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::entities::session::Session;
|
||||
|
||||
/// Wire shape for `GET /api/admin/sessions`. Contains everything the
|
||||
/// admin table renders and **nothing the raw session entity would
|
||||
/// leak** (refresh token, OIDC ID-token, full DPoP thumbprint).
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct SessionSummaryDto {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_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
|
||||
/// in the admin table. Complements the auth-badges surface.
|
||||
pub is_bound: bool,
|
||||
/// First 8 chars of the DPoP thumbprint when bound, `None` otherwise.
|
||||
/// Enough to distinguish two bindings of the same user across
|
||||
/// devices at a glance; not enough to leak the full jkt.
|
||||
pub dpop_jkt_prefix: Option<String>,
|
||||
/// `true` when the row is revoked. Present because the panel has an
|
||||
/// opt-in "include revoked" checkbox — active-only listings will
|
||||
/// always show `false` here, but forensics listings need the flag.
|
||||
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.
|
||||
pub is_active: bool,
|
||||
/// OIDC session identifier when the login came through OIDC and the
|
||||
/// IdP emitted `sid` — otherwise `None`. Useful when an operator is
|
||||
/// correlating with the upstream IdP's session log.
|
||||
pub oidc_sid: Option<String>,
|
||||
}
|
||||
|
||||
impl From<Session> for SessionSummaryDto {
|
||||
fn from(s: Session) -> Self {
|
||||
let is_revoked = s.is_revoked();
|
||||
let is_expired = s.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>());
|
||||
Self {
|
||||
id: s.id(),
|
||||
user_id: s.user_id(),
|
||||
created_at: s.created_at(),
|
||||
expires_at: s.expires_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,
|
||||
oidc_sid: s.oidc_sid().map(str::to_owned),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
|
||||
fn base(revoked: bool, jkt: Option<&str>) -> Session {
|
||||
let mut s = Session::new(
|
||||
Uuid::new_v4(),
|
||||
"refresh-token".to_string(),
|
||||
Some("192.0.2.1".to_string()),
|
||||
Some("Mozilla/5.0".to_string()),
|
||||
30,
|
||||
Uuid::new_v4(),
|
||||
);
|
||||
if revoked {
|
||||
s.revoke();
|
||||
}
|
||||
if let Some(k) = jkt {
|
||||
s = s.with_dpop_jkt(k.to_string());
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dto_never_leaks_refresh_token() {
|
||||
let s = base(false, None);
|
||||
let dto = SessionSummaryDto::from(s);
|
||||
let json = serde_json::to_string(&dto).unwrap();
|
||||
assert!(
|
||||
!json.contains("refresh-token"),
|
||||
"refresh_token must never appear in the wire shape"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dto_truncates_dpop_jkt_to_8_chars() {
|
||||
// 44-char base64url thumbprint (SHA-256 → 32 bytes → ceil(32/3)*4 = 44)
|
||||
let full = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH";
|
||||
let dto = SessionSummaryDto::from(base(false, Some(full)));
|
||||
assert_eq!(dto.dpop_jkt_prefix.as_deref(), Some("abcdefgh"));
|
||||
assert!(dto.is_bound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dto_unbound_session_has_no_prefix() {
|
||||
let dto = SessionSummaryDto::from(base(false, None));
|
||||
assert_eq!(dto.dpop_jkt_prefix, None);
|
||||
assert!(!dto.is_bound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_active_false_when_revoked() {
|
||||
let dto = SessionSummaryDto::from(base(true, None));
|
||||
assert!(dto.is_revoked);
|
||||
assert!(!dto.is_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_active_true_for_fresh_unrevoked_session() {
|
||||
let dto = SessionSummaryDto::from(base(false, Some("jkt-abc")));
|
||||
assert!(!dto.is_revoked);
|
||||
assert!(dto.is_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_raw_expired_session_is_not_active() {
|
||||
let past = Utc::now() - Duration::days(1);
|
||||
let s = Session::from_raw(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
"rt".to_string(),
|
||||
past,
|
||||
None,
|
||||
None,
|
||||
past,
|
||||
false,
|
||||
Uuid::new_v4(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let dto = SessionSummaryDto::from(s);
|
||||
assert!(!dto.is_active);
|
||||
assert!(!dto.is_revoked); // exp-but-unrevoked distinct from revoked
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,19 @@ pub struct ListUsersQueryDto {
|
||||
pub summary: Option<bool>,
|
||||
}
|
||||
|
||||
/// Query parameters for the admin sessions listing.
|
||||
///
|
||||
/// `user_id` is a String (not `Uuid`) because bad UUIDs need a clean
|
||||
/// 400 response — the handler parses and rejects malformed input.
|
||||
/// `include_revoked` defaults to `false` at the handler layer.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ListSessionsQueryDto {
|
||||
pub user_id: Option<String>,
|
||||
pub include_revoked: Option<bool>,
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
|
||||
/// One row of the dashboard's quota panel — usage aggregate for a
|
||||
/// single drive kind. Unlimited caps are excluded from `capped_quota_bytes`
|
||||
/// and counted in `unlimited_count` so the panel can render the ratio
|
||||
|
||||
@@ -497,6 +497,24 @@ pub trait SessionStoragePort: Send + Sync + 'static {
|
||||
/// carries a thumbprint (anti-downgrade invariant, see
|
||||
/// `docs/plan/dpop.md`).
|
||||
async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Fetch a single session by id. Used by admin surfaces that need
|
||||
/// to resolve `target_user_id` for audit lines before a mutation.
|
||||
/// Returns `NotFound` when the id doesn't match any row.
|
||||
async fn get_session_by_id(&self, session_id: Uuid) -> Result<Session, DomainError>;
|
||||
|
||||
/// Paginated cross-user listing for the admin sessions panel.
|
||||
/// `user_id_filter` narrows to a single user when `Some`; `None`
|
||||
/// spans all users. `include_revoked = false` (the default UX)
|
||||
/// returns only rows where `revoked = false AND expires_at > NOW()`.
|
||||
/// Ordered newest first (`created_at DESC`).
|
||||
async fn list_sessions_paginated(
|
||||
&self,
|
||||
user_id_filter: Option<Uuid>,
|
||||
include_revoked: bool,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Session>, DomainError>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -803,7 +803,12 @@ impl AuthApplicationService {
|
||||
Ok(UserDto::from(created_user))
|
||||
}
|
||||
|
||||
pub async fn login(&self, dto: LoginDto) -> Result<AuthResponseDto, DomainError> {
|
||||
pub async fn login(
|
||||
&self,
|
||||
dto: LoginDto,
|
||||
client_ip: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
) -> Result<AuthResponseDto, DomainError> {
|
||||
// Gate: policy may forbid password logins entirely (either the
|
||||
// legacy OIDC-only mode or the newer `OXICLOUD_AUTH_METHODS`
|
||||
// allowlist without `password`). Refuse BEFORE the user lookup
|
||||
@@ -1006,7 +1011,7 @@ impl AuthApplicationService {
|
||||
// handshake (Phase 1, `login/ke3`). Both paths converge here
|
||||
// so lifecycle + token + session-family semantics stay in
|
||||
// one place.
|
||||
self.mint_session_for_authenticated_user(user, dto.dpop_jkt)
|
||||
self.mint_session_for_authenticated_user(user, dto.dpop_jkt, client_ip, user_agent)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1034,6 +1039,8 @@ impl AuthApplicationService {
|
||||
&self,
|
||||
mut user: crate::domain::entities::user::User,
|
||||
dpop_jkt: Option<String>,
|
||||
client_ip: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
) -> Result<AuthResponseDto, DomainError> {
|
||||
// Lifecycle: dispatch login BEFORE register_login() so hooks
|
||||
// observing `last_login_at().is_none()` see "first ever login"
|
||||
@@ -1092,8 +1099,8 @@ impl AuthApplicationService {
|
||||
let mut session = Session::new(
|
||||
user.id(),
|
||||
refresh_token.clone(),
|
||||
None, // IP (can be added from the HTTP layer)
|
||||
None, // User-Agent (can be added from the HTTP layer)
|
||||
client_ip,
|
||||
user_agent,
|
||||
self.token_service.refresh_token_expiry_days(),
|
||||
Uuid::new_v4(),
|
||||
);
|
||||
@@ -1168,6 +1175,8 @@ impl AuthApplicationService {
|
||||
token: &str,
|
||||
incoming_challenge: Option<&str>,
|
||||
cross_browser_confirmed: bool,
|
||||
client_ip: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
) -> Result<MagicLinkRedeemResult, DomainError> {
|
||||
let repo = self.magic_link_repo.as_ref().ok_or_else(|| {
|
||||
DomainError::new(
|
||||
@@ -1355,8 +1364,8 @@ impl AuthApplicationService {
|
||||
let session = Session::new(
|
||||
user.id(),
|
||||
refresh_token.clone(),
|
||||
None,
|
||||
None,
|
||||
client_ip,
|
||||
user_agent,
|
||||
self.token_service.refresh_token_expiry_days(),
|
||||
Uuid::new_v4(),
|
||||
);
|
||||
@@ -1447,6 +1456,8 @@ impl AuthApplicationService {
|
||||
pub async fn refresh_token(
|
||||
&self,
|
||||
dto: RefreshTokenDto,
|
||||
client_ip: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
) -> Result<AuthResponseDto, DomainError> {
|
||||
// Get valid session
|
||||
let session = self
|
||||
@@ -1525,8 +1536,8 @@ impl AuthApplicationService {
|
||||
let mut new_session = Session::new(
|
||||
user.id(),
|
||||
new_refresh_token.clone(),
|
||||
None,
|
||||
None,
|
||||
client_ip,
|
||||
user_agent,
|
||||
self.token_service.refresh_token_expiry_days(),
|
||||
session.family_id(),
|
||||
);
|
||||
@@ -2931,6 +2942,78 @@ impl AuthApplicationService {
|
||||
Ok(names.into_iter().flatten().collect())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Admin Session Management Methods
|
||||
// ========================================================================
|
||||
//
|
||||
// AuthZ posture: /api/admin/* is already protected by a
|
||||
// `require_admin` router layer (see
|
||||
// `interfaces/api/routes.rs::admin_router`) — but every admin
|
||||
// method here still calls `require_admin_caller` as a
|
||||
// defense-in-depth check, matching the pattern
|
||||
// `list_users_including_external_with_perms` established. If a
|
||||
// handler is ever wired outside the /admin subtree, the AuthZ
|
||||
// still holds.
|
||||
|
||||
/// List sessions for the admin panel. `user_id_filter = Some(uuid)`
|
||||
/// narrows to one user; `None` returns cross-user. `include_revoked`
|
||||
/// controls whether to show revoked / expired rows — default UX
|
||||
/// hides them (checkbox to opt in for forensics).
|
||||
pub async fn admin_list_sessions_with_perms<A: AuthorizationEngine>(
|
||||
&self,
|
||||
authorization: &A,
|
||||
caller_id: Uuid,
|
||||
user_id_filter: Option<Uuid>,
|
||||
include_revoked: bool,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<crate::application::dtos::session_dto::SessionSummaryDto>, DomainError> {
|
||||
self.require_admin_caller(authorization, caller_id).await?;
|
||||
let sessions = self
|
||||
.session_storage
|
||||
.list_sessions_paginated(user_id_filter, include_revoked, limit, offset)
|
||||
.await?;
|
||||
Ok(sessions
|
||||
.into_iter()
|
||||
.map(crate::application::dtos::session_dto::SessionSummaryDto::from)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Admin-driven session revocation. Sets `revoked = true` — the
|
||||
/// row remains for audit visibility, but its refresh token is
|
||||
/// dead and the next access-token refresh 401s naturally.
|
||||
///
|
||||
/// Emits an audit line + counter increment so operators can trace
|
||||
/// who killed which session and when.
|
||||
pub async fn admin_revoke_session_with_perms<A: AuthorizationEngine>(
|
||||
&self,
|
||||
authorization: &A,
|
||||
caller_id: Uuid,
|
||||
session_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
self.require_admin_caller(authorization, caller_id).await?;
|
||||
// Resolve target user for the audit line before revocation —
|
||||
// once the session row is revoked the user_id is still readable
|
||||
// but the ORDER is stable this way.
|
||||
let target_user_id = self
|
||||
.session_storage
|
||||
.get_session_by_id(session_id)
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.user_id());
|
||||
self.session_storage.revoke_session(session_id).await?;
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "admin.session_revoked",
|
||||
caller_id = %caller_id,
|
||||
session_id = %session_id,
|
||||
target_user_id = target_user_id.map(|u| u.to_string()).unwrap_or_default(),
|
||||
"👮🏻♂️ Admin revoked session",
|
||||
);
|
||||
metrics::counter!("oxicloud_admin_session_revoked_total").increment(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Admin User Management Methods
|
||||
// ========================================================================
|
||||
@@ -3753,6 +3836,8 @@ impl AuthApplicationService {
|
||||
code: &str,
|
||||
state: &str,
|
||||
locale_registry: &crate::common::locale::LocaleRegistry,
|
||||
client_ip: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
) -> Result<OidcCallbackResult, DomainError> {
|
||||
// 0. Validate CSRF state and retrieve PKCE verifier + nonce + optional NC token
|
||||
// (entry is auto-expired by moka TTL — remove returns None if expired)
|
||||
@@ -4276,8 +4361,8 @@ impl AuthApplicationService {
|
||||
let mut session = Session::new(
|
||||
user.id(),
|
||||
refresh_token.clone(),
|
||||
None,
|
||||
None,
|
||||
client_ip,
|
||||
user_agent,
|
||||
self.token_service.refresh_token_expiry_days(),
|
||||
Uuid::new_v4(),
|
||||
)
|
||||
@@ -4492,11 +4577,15 @@ mod phase4_gate_integration_tests {
|
||||
let user_id = seed_user_with_password(&pool, &hasher, &email, "s3cret-passphrase").await;
|
||||
|
||||
// Baseline — no envelope, no migration mark → legacy works.
|
||||
svc.login(crate::application::dtos::user_dto::LoginDto {
|
||||
username: email.clone(),
|
||||
password: "s3cret-passphrase".to_string(),
|
||||
dpop_jkt: None,
|
||||
})
|
||||
svc.login(
|
||||
crate::application::dtos::user_dto::LoginDto {
|
||||
username: email.clone(),
|
||||
password: "s3cret-passphrase".to_string(),
|
||||
dpop_jkt: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("baseline legacy login must succeed");
|
||||
|
||||
@@ -4510,11 +4599,15 @@ mod phase4_gate_integration_tests {
|
||||
// but AccessDenied with the exact message the handler layer
|
||||
// remaps to `403 OpaqueLoginRequired`.
|
||||
let refused = svc
|
||||
.login(crate::application::dtos::user_dto::LoginDto {
|
||||
username: email.clone(),
|
||||
password: "s3cret-passphrase".to_string(),
|
||||
dpop_jkt: None,
|
||||
})
|
||||
.login(
|
||||
crate::application::dtos::user_dto::LoginDto {
|
||||
username: email.clone(),
|
||||
password: "s3cret-passphrase".to_string(),
|
||||
dpop_jkt: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("legacy login must be refused post-migration");
|
||||
assert_eq!(
|
||||
@@ -4534,11 +4627,15 @@ mod phase4_gate_integration_tests {
|
||||
// password check specifically so an attacker without the
|
||||
// password learns nothing about migration state.
|
||||
let wrong = svc
|
||||
.login(crate::application::dtos::user_dto::LoginDto {
|
||||
username: email.clone(),
|
||||
password: "wrong-password".to_string(),
|
||||
dpop_jkt: None,
|
||||
})
|
||||
.login(
|
||||
crate::application::dtos::user_dto::LoginDto {
|
||||
username: email.clone(),
|
||||
password: "wrong-password".to_string(),
|
||||
dpop_jkt: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("wrong password must still fail");
|
||||
assert_eq!(wrong.message, "Invalid credentials");
|
||||
@@ -4551,11 +4648,15 @@ mod phase4_gate_integration_tests {
|
||||
.clear_registration(user_id)
|
||||
.await
|
||||
.expect("clear registration");
|
||||
svc.login(crate::application::dtos::user_dto::LoginDto {
|
||||
username: email,
|
||||
password: "s3cret-passphrase".to_string(),
|
||||
dpop_jkt: None,
|
||||
})
|
||||
svc.login(
|
||||
crate::application::dtos::user_dto::LoginDto {
|
||||
username: email,
|
||||
password: "s3cret-passphrase".to_string(),
|
||||
dpop_jkt: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("legacy login must succeed again after admin clear_registration");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user