feat(session): handle sessions for admin

This commit is contained in:
Edouard Vanbelle
2026-08-09 03:39:19 +02:00
parent 1b9d812175
commit bee856fbd0
17 changed files with 997 additions and 35 deletions
+1
View File
@@ -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;
+164
View File
@@ -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
}
}
+13
View File
@@ -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
+18
View File
@@ -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");
}
@@ -58,6 +58,23 @@ pub trait SessionRepository: Send + Sync + 'static {
async fn get_sessions_by_user_id(&self, user_id: Uuid)
-> SessionRepositoryResult<Vec<Session>>;
/// Paginated listing for the admin sessions panel. Cross-user by
/// default; `user_id_filter = Some(uuid)` narrows to one user.
/// `include_revoked = false` (the default UX) filters to sessions
/// that are BOTH non-revoked AND non-expired — what an operator
/// would call "active right now". `include_revoked = true` shows
/// everything for incident forensics.
///
/// Ordered by `created_at DESC` — newest first, matching the
/// existing `get_sessions_by_user_id` convention.
async fn list_sessions_paginated(
&self,
user_id_filter: Option<Uuid>,
include_revoked: bool,
limit: i64,
offset: i64,
) -> SessionRepositoryResult<Vec<Session>>;
/// Revokes a specific session
async fn revoke_session(&self, session_id: Uuid) -> SessionRepositoryResult<()>;
@@ -223,6 +223,66 @@ impl SessionRepository for SessionPgRepository {
Ok(sessions)
}
async fn list_sessions_paginated(
&self,
user_id_filter: Option<Uuid>,
include_revoked: bool,
limit: i64,
offset: i64,
) -> SessionRepositoryResult<Vec<Session>> {
// Single SQL with nullable-user-id + include-revoked flag
// baked in as parameters, rather than four hand-forked
// queries. `$1::uuid IS NULL` short-circuits when no filter is
// set; `$2 OR (revoked = false AND expires_at > NOW())` folds
// the active-only rule into one predicate. Both branches use
// the same index (`idx_sessions_user_id`) on the filtered
// path, and a full table scan bounded by `LIMIT` on the
// unfiltered path — acceptable for an admin-triggered view
// that operators paginate through.
let rows = sqlx::query(
r#"
SELECT
id, user_id, refresh_token, expires_at,
ip_address, user_agent, created_at, revoked, family_id,
oidc_id_token, oidc_sid, dpop_jkt
FROM auth.sessions
WHERE ($1::uuid IS NULL OR user_id = $1)
AND ($2 OR (revoked = false AND expires_at > NOW()))
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
"#,
)
.bind(user_id_filter)
.bind(include_revoked)
.bind(limit)
.bind(offset)
.fetch_all(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
let sessions = rows
.into_iter()
.map(|row| {
Session::from_raw(
row.get("id"),
row.get("user_id"),
row.get("refresh_token"),
row.get("expires_at"),
row.get("ip_address"),
row.get("user_agent"),
row.get("created_at"),
row.get("revoked"),
row.get("family_id"),
row.get("oidc_id_token"),
row.get("oidc_sid"),
row.get("dpop_jkt"),
)
})
.collect();
Ok(sessions)
}
/// Revokes a specific session using a transaction
async fn revoke_session(&self, session_id: Uuid) -> SessionRepositoryResult<()> {
let id = session_id; // Copy for use in closure
@@ -649,4 +709,28 @@ impl SessionStoragePort for SessionPgRepository {
.await
.map_err(DomainError::from)
}
async fn get_session_by_id(&self, session_id: Uuid) -> Result<Session, DomainError> {
SessionRepository::get_session_by_id(self, session_id)
.await
.map_err(DomainError::from)
}
async fn list_sessions_paginated(
&self,
user_id_filter: Option<Uuid>,
include_revoked: bool,
limit: i64,
offset: i64,
) -> Result<Vec<Session>, DomainError> {
SessionRepository::list_sessions_paginated(
self,
user_id_filter,
include_revoked,
limit,
offset,
)
.await
.map_err(DomainError::from)
}
}
+107 -1
View File
@@ -17,7 +17,8 @@ use crate::application::dtos::plugin_dto::{
};
use crate::application::dtos::settings_dto::{
AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, DriveKindUsageDto,
ListUsersQueryDto, MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto,
ListSessionsQueryDto, ListUsersQueryDto, MigrationStateDto, SaveOidcSettingsDto,
SaveStorageSettingsDto,
SendSmtpTestDto, SmtpInfoDto, SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto,
TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto,
};
@@ -108,6 +109,11 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
.route("/users", post(create_user))
.route("/users/{id}", get(get_user))
.route("/users/{id}", delete(delete_user))
// Session management (DPoP admin panel — see docs/plan/dpop.md
// Gate 10). List is global cross-user with `?user_id=` narrow;
// revoke sets `revoked=true` (row stays for audit).
.route("/sessions", get(list_sessions))
.route("/sessions/{id}", delete(revoke_session))
.route("/users/{id}/role", put(update_user_role))
.route("/users/{id}/active", put(update_user_active))
.route("/users/{id}/quota", put(update_user_quota))
@@ -1189,6 +1195,106 @@ pub async fn delete_user(
))
}
/// GET /api/admin/sessions?user_id=&include_revoked=&limit=&offset= — list sessions
///
/// Global cross-user listing by default. `user_id` narrows to one
/// user; omit for cross-user. `include_revoked=true` opts into
/// showing revoked / expired rows for forensics (default hides).
/// Response is `{sessions, limit, offset}` — no total count (would
/// require a second scan; the panel paginates on presence of
/// exactly `limit` rows returned).
#[utoipa::path(
get,
path = "/api/admin/sessions",
params(
("user_id" = Option<String>, Query, description = "Narrow to one user (UUID); omit for cross-user"),
("include_revoked" = Option<bool>, Query, description = "Include revoked + expired rows (default false — active only)"),
("limit" = Option<i64>, Query, description = "Max rows to return (default 100, max 500)"),
("offset" = Option<i64>, Query, description = "Pagination offset")
),
responses(
(status = 200, description = "List of sessions"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required")
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn list_sessions(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Query(query): Query<ListSessionsQueryDto>,
) -> Result<impl IntoResponse, AppError> {
let auth = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let limit = query.limit.unwrap_or(100).min(500);
let offset = query.offset.unwrap_or(0);
let include_revoked = query.include_revoked.unwrap_or(false);
let user_id_filter = match query.user_id.as_deref() {
Some(s) => Some(Uuid::parse_str(s).map_err(|_| AppError::bad_request("Invalid user_id"))?),
None => None,
};
let sessions = auth
.auth_application_service
.admin_list_sessions_with_perms(
state.authorization.as_ref(),
auth_user.id,
user_id_filter,
include_revoked,
limit,
offset,
)
.await
.map_err(AppError::from)?;
Ok(Json(serde_json::json!({
"sessions": sessions,
"limit": limit,
"offset": offset,
})))
}
/// DELETE /api/admin/sessions/:id — revoke a session
#[utoipa::path(
delete,
path = "/api/admin/sessions/{id}",
params(("id" = String, Path, description = "Session UUID")),
responses(
(status = 200, description = "Session revoked"),
(status = 400, description = "Invalid UUID"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required"),
(status = 404, description = "Session not found")
),
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn revoke_session(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let session_id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
let auth = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
auth.auth_application_service
.admin_revoke_session_with_perms(state.authorization.as_ref(), auth_user.id, session_id)
.await
.map_err(AppError::from)?;
Ok((
StatusCode::OK,
Json(serde_json::json!({ "message": "Session revoked" })),
))
}
/// PUT /api/admin/users/:id/role — change user role
#[utoipa::path(
put,
+41 -3
View File
@@ -393,10 +393,19 @@ pub async fn login(
));
}
// Extract the User-Agent once — the audit lines already carry
// `client_ip` on the request-scope span; passing both to the
// service lets `create_session` capture them on the row so the
// admin panel can show *who logged in from where*.
let user_agent = headers
.get(axum::http::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
// Try the normal login process
match auth_service
.auth_application_service
.login(dto.clone())
.login(dto.clone(), Some(client_ip.clone()), user_agent.clone())
.await
{
Ok(auth_response) => {
@@ -547,6 +556,7 @@ pub async fn login(
)]
pub async fn refresh_token(
State(state): State<Arc<AppState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<Response, AppError> {
@@ -568,9 +578,19 @@ pub async fn refresh_token(
refresh_token: refresh_tok,
};
// Refresh rotates the session row — capture current IP + UA so the
// NEW row's `ip_address`/`user_agent` reflect the latest observed
// client (see `sessions.rotate_session`). Old row keeps its own
// capture from creation time.
let client_ip = client_ip_from_parts(&headers, Some(peer), false);
let user_agent = headers
.get(axum::http::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let auth_response = auth_service
.auth_application_service
.refresh_token(dto)
.refresh_token(dto, Some(client_ip), user_agent)
.await?;
tracing::info!("Token refresh successful, new token issued");
@@ -1560,6 +1580,8 @@ pub async fn oidc_unlink(
)]
pub async fn oidc_callback(
State(state): State<Arc<AppState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Query(query): Query<OidcCallbackQueryDto>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state
@@ -1579,6 +1601,16 @@ pub async fn oidc_callback(
tracing::info!("OIDC callback received with code");
// Capture IP + UA so the OIDC-minted session row lands populated
// (admin panel would otherwise show "—" for SSO logins). Callback
// is a browser-initiated GET after the IdP redirect, so peer is
// the browser and User-Agent is the browser's.
let client_ip = client_ip_from_parts(&headers, Some(peer), false);
let user_agent = headers
.get(axum::http::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
// Exchange code, validate state/nonce/PKCE, authenticate user.
// Any Err path (expired state on refresh, consumed code on replay,
// anti-takeover email refusal, etc.) is caught below and turned
@@ -1587,7 +1619,13 @@ pub async fn oidc_callback(
// mid-navigation from the IdP, not the SPA. The SPA login page
// renders localized copy per key.
let result = match auth_app
.oidc_callback(&query.code, &query.state, &state.locale_registry)
.oidc_callback(
&query.code,
&query.state,
&state.locale_registry,
Some(client_ip),
user_agent,
)
.await
{
Ok(r) => r,
@@ -148,6 +148,7 @@ struct RedeemQuery {
)]
async fn redeem_magic_link(
State(state): State<Arc<AppState>>,
axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo<std::net::SocketAddr>,
Path(token): Path<String>,
Query(query): Query<RedeemQuery>,
RequestLocale(locale): RequestLocale,
@@ -171,12 +172,26 @@ async fn redeem_magic_link(
.map(|v| v == "1" || v == "true")
.unwrap_or(false);
// Capture IP + UA for the newly minted session row (admin sessions
// panel renders these; NULLs would show as "—").
let client_ip = crate::interfaces::middleware::trusted_proxy::client_ip_from_parts(
&headers,
Some(peer),
false,
);
let user_agent = headers
.get(axum::http::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
match auth_svc
.auth_application_service
.redeem_magic_link(
&token,
incoming_challenge.as_deref(),
cross_browser_confirmed,
Some(client_ip),
user_agent,
)
.await
{
@@ -682,6 +682,8 @@ pub async fn login_ke1(
)]
pub async fn login_ke3(
State(state): State<Arc<AppState>>,
axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo<std::net::SocketAddr>,
headers: axum::http::HeaderMap,
Json(dto): Json<OpaqueLoginKe3Dto>,
) -> Result<impl IntoResponse, AppError> {
let _svc = require_opaque_service(&state)?;
@@ -764,12 +766,23 @@ pub async fn login_ke3(
invalid_credentials()
})?;
// Capture client IP + User-Agent so `sessions.ip_address` /
// `user_agent` land populated instead of NULL (admin panel would
// otherwise render "—"). Both are per-session and only refresh
// on rotation, matching the login pattern.
let client_ip =
crate::interfaces::middleware::trusted_proxy::client_ip_from_parts(&headers, Some(peer), false);
let user_agent = headers
.get(axum::http::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
// Mint the session BEFORE stamping opaque_migrated_at — if the
// session mint fails (rare, but not impossible under DB failure),
// we don't want to have flipped the migration flag for a user
// whose login didn't actually complete.
let session = auth
.mint_session_for_authenticated_user(user, dto.dpop_jkt)
.mint_session_for_authenticated_user(user, dto.dpop_jkt, Some(client_ip), user_agent)
.await
.map_err(AppError::from)?;