feat(session): handle sessions for admin
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
import type {
|
||||
AdminSessionsPage,
|
||||
AdminUsersPage,
|
||||
Drive,
|
||||
DriveMember,
|
||||
@@ -241,6 +242,39 @@ export async function deleteDriveAdmin(driveId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sessions (admin panel) ──────────────────────────────────────────────
|
||||
|
||||
/** Options for {@link listAdminSessions}. */
|
||||
export interface ListSessionsOpts {
|
||||
/** Narrow to one user's sessions; omit for cross-user listing. */
|
||||
userId?: string;
|
||||
/** Include revoked / expired rows. Default `false` (active-only UX). */
|
||||
includeRevoked?: boolean;
|
||||
/** Page size — server caps at 500. */
|
||||
limit?: number;
|
||||
/** Pagination offset. */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/** Global sessions listing — `GET /api/admin/sessions`. */
|
||||
export function listAdminSessions(opts: ListSessionsOpts = {}): Promise<AdminSessionsPage> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.userId) params.set('user_id', opts.userId);
|
||||
if (opts.includeRevoked) params.set('include_revoked', 'true');
|
||||
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
|
||||
if (opts.offset !== undefined) params.set('offset', String(opts.offset));
|
||||
const qs = params.toString();
|
||||
return apiJson<AdminSessionsPage>(`/api/admin/sessions${qs ? '?' + qs : ''}`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
}
|
||||
|
||||
/** Revoke a session — `DELETE /api/admin/sessions/{id}`. Sets
|
||||
* `revoked=true`; the row stays in the DB for audit visibility. */
|
||||
export function revokeAdminSession(sessionId: string): Promise<void> {
|
||||
return mutate(`/api/admin/sessions/${encodeURIComponent(sessionId)}`, 'DELETE');
|
||||
}
|
||||
|
||||
// ── Users ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** List the compact rows rendered by the management table; full account
|
||||
|
||||
@@ -734,3 +734,31 @@ export interface Finding {
|
||||
detail: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin sessions-panel row shape. Backend: `SessionSummaryDto` in
|
||||
* `src/application/dtos/session_dto.rs`. Deliberately narrower than
|
||||
* the DB row — the refresh token is never serialised, and the full
|
||||
* DPoP thumbprint is truncated to an 8-char prefix so admins viewing
|
||||
* other users' sessions can't exfiltrate the full binding fingerprint.
|
||||
*/
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
is_bound: boolean;
|
||||
dpop_jkt_prefix: string | null;
|
||||
is_revoked: boolean;
|
||||
is_active: boolean;
|
||||
oidc_sid: string | null;
|
||||
}
|
||||
|
||||
/** Wire response of `GET /api/admin/sessions`. */
|
||||
export interface AdminSessionsPage {
|
||||
sessions: SessionSummary[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,12 @@
|
||||
icon: 'users',
|
||||
section: 'admin-users'
|
||||
},
|
||||
{
|
||||
href: '/admin/sessions',
|
||||
label: t('admin.sessions', 'Sessions'),
|
||||
icon: 'key',
|
||||
section: 'admin-sessions'
|
||||
},
|
||||
{
|
||||
href: '/admin/drives',
|
||||
label: t('admin.drives', 'Drives'),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shortUserAgent } from './userAgent';
|
||||
|
||||
describe('shortUserAgent', () => {
|
||||
it('placeholder for missing input', () => {
|
||||
expect(shortUserAgent(null)).toBe('—');
|
||||
expect(shortUserAgent(undefined)).toBe('—');
|
||||
expect(shortUserAgent('')).toBe('—');
|
||||
});
|
||||
|
||||
it('device-auth marker passes through unchanged', () => {
|
||||
expect(shortUserAgent('device:my-tv-42')).toBe('device:my-tv-42');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
|
||||
'Chrome on Mac'
|
||||
],
|
||||
[
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36',
|
||||
'Chrome on Windows'
|
||||
],
|
||||
[
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36',
|
||||
'Chrome on Linux'
|
||||
],
|
||||
[
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0',
|
||||
'Firefox on Windows'
|
||||
],
|
||||
[
|
||||
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0',
|
||||
'Firefox on Linux'
|
||||
],
|
||||
[
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15',
|
||||
'Safari on Mac'
|
||||
],
|
||||
[
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
|
||||
'Safari on iOS'
|
||||
],
|
||||
[
|
||||
// iPad on iPadOS 13+ ships a UA with "Macintosh" — must NOT
|
||||
// mis-detect as Mac. Guarded by iOS-first ordering.
|
||||
'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
|
||||
'Safari on iOS'
|
||||
],
|
||||
[
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36',
|
||||
'Chrome on Android'
|
||||
],
|
||||
[
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.2535.51',
|
||||
'Edge on Windows'
|
||||
],
|
||||
[
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 OPR/110.0.0.0',
|
||||
'Opera on Linux'
|
||||
],
|
||||
['curl/8.7.1', 'curl'],
|
||||
['Wget/1.21.4', 'wget'],
|
||||
['Mozilla/5.0 (Nextcloud desktop client 3.14.2 stable-x86_64)', 'Nextcloud client'],
|
||||
['node-fetch/1.0 (+https://github.com/bitinn/node-fetch)', 'Node']
|
||||
])('%s → %s', (ua, expected) => {
|
||||
expect(shortUserAgent(ua)).toBe(expected);
|
||||
});
|
||||
|
||||
it('truncates unknown UA shapes past 40 chars', () => {
|
||||
const long = 'SomeWeirdCrawler/1.0 with a very long description of its capabilities';
|
||||
const result = shortUserAgent(long);
|
||||
expect(result.endsWith('…')).toBe(true);
|
||||
expect(result.length).toBeLessThanOrEqual(41);
|
||||
});
|
||||
|
||||
it('returns short unknown UA verbatim', () => {
|
||||
expect(shortUserAgent('MyBot/1.0')).toBe('MyBot/1.0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Parse a raw HTTP `User-Agent` string into a compact human label like
|
||||
* "Chrome on Mac" for admin/session UIs. Not a general-purpose UA
|
||||
* parser — pragmatic regex-based buckets covering the browsers +
|
||||
* operating systems that make up ~99% of real-world traffic, plus the
|
||||
* OxiCloud-specific device-auth prefix.
|
||||
*
|
||||
* Order of detection matters:
|
||||
* * Edge / Opera / Firefox before Chrome (they all include `Chrome/…`)
|
||||
* * Chrome before Safari (Chrome includes `Safari/…`)
|
||||
* * Version check guards Safari against matching a WebKit-based crawler
|
||||
*
|
||||
* `null` / `undefined` / empty → `"—"` so the admin table renders a
|
||||
* consistent placeholder without every callsite writing `?? '—'`.
|
||||
*/
|
||||
export function shortUserAgent(ua: string | null | undefined): string {
|
||||
if (!ua) return '—';
|
||||
|
||||
// Device-authorization grant sessions carry a bespoke marker
|
||||
// (`device:<client_name>`) instead of a browser UA. Pass through.
|
||||
if (ua.startsWith('device:')) return ua;
|
||||
|
||||
// Browser detection — order matters.
|
||||
let browser: string | null = null;
|
||||
if (/\bEdg[eA]?\//.test(ua)) browser = 'Edge';
|
||||
else if (/\bOPR\/|Opera\//.test(ua)) browser = 'Opera';
|
||||
else if (/\bFirefox\/|FxiOS\//.test(ua)) browser = 'Firefox';
|
||||
else if (/\bChrome\//.test(ua)) browser = 'Chrome';
|
||||
else if (/\bSafari\//.test(ua) && /\bVersion\//.test(ua)) browser = 'Safari';
|
||||
else if (/\bcurl\//.test(ua)) browser = 'curl';
|
||||
else if (/\bwget/i.test(ua)) browser = 'wget';
|
||||
else if (/\bNextcloud\b/i.test(ua)) browser = 'Nextcloud client';
|
||||
else if (/\bnode\b/i.test(ua)) browser = 'Node';
|
||||
|
||||
// OS detection — iOS/iPad before Mac (iPad UAs include "Macintosh" on
|
||||
// modern iPadOS "desktop mode"; without the iPad check first they'd
|
||||
// be miscategorised as Mac).
|
||||
let os: string | null = null;
|
||||
if (/Windows/i.test(ua)) os = 'Windows';
|
||||
else if (/iPhone|iPad|iPod/i.test(ua)) os = 'iOS';
|
||||
else if (/Android/i.test(ua)) os = 'Android';
|
||||
else if (/Mac OS X|Macintosh/i.test(ua)) os = 'Mac';
|
||||
else if (/CrOS/i.test(ua)) os = 'ChromeOS';
|
||||
else if (/Linux/i.test(ua)) os = 'Linux';
|
||||
else if (/FreeBSD|OpenBSD|NetBSD/i.test(ua)) os = 'BSD';
|
||||
|
||||
if (browser && os) return `${browser} on ${os}`;
|
||||
if (browser) return browser;
|
||||
if (os) return os;
|
||||
|
||||
// Unknown shape — truncate the raw string so a huge UA doesn't
|
||||
// blow up the table column width. Full string still available in
|
||||
// the row's `title=` tooltip.
|
||||
return ua.length > 40 ? ua.slice(0, 40) + '…' : ua;
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
installPlugin,
|
||||
listPlugins,
|
||||
listUsers,
|
||||
listAdminSessions,
|
||||
revokeAdminSession,
|
||||
getUserAdmin,
|
||||
migrationAction,
|
||||
reextractAudioMetadata,
|
||||
@@ -73,8 +75,10 @@
|
||||
DriveMember,
|
||||
DrivePolicies,
|
||||
DrivePoliciesPartial,
|
||||
SessionSummary,
|
||||
User
|
||||
} from '$lib/api/types';
|
||||
import { shortUserAgent } from '$lib/utils/userAgent';
|
||||
import { triggerJob } from '$lib/api/endpoints/adminJobs';
|
||||
import { serverStatus } from '$lib/stores/serverStatus.svelte';
|
||||
import AdminJobsPanel from '$lib/components/AdminJobsPanel.svelte';
|
||||
@@ -177,6 +181,7 @@
|
||||
type Tab =
|
||||
| 'dashboard'
|
||||
| 'users'
|
||||
| 'sessions'
|
||||
| 'drives'
|
||||
| 'mounts'
|
||||
| 'plugins'
|
||||
@@ -188,6 +193,7 @@
|
||||
const VALID_TABS: readonly Tab[] = [
|
||||
'dashboard',
|
||||
'users',
|
||||
'sessions',
|
||||
'drives',
|
||||
'mounts',
|
||||
'plugins',
|
||||
@@ -221,6 +227,8 @@
|
||||
return t('admin.dashboard', 'Dashboard');
|
||||
case 'users':
|
||||
return t('admin.users', 'Users');
|
||||
case 'sessions':
|
||||
return t('admin.sessions', 'Sessions');
|
||||
case 'drives':
|
||||
return t('admin.drives', 'Drives');
|
||||
case 'mounts':
|
||||
@@ -842,6 +850,56 @@
|
||||
let resetError = $state<string | null>(null);
|
||||
let resetting = $state(false);
|
||||
|
||||
// Sessions (admin panel — see task #52 / docs/plan/dpop.md Gate 10).
|
||||
// Global cross-user listing by default; user-filter dropdown narrows.
|
||||
// Active-only by default (hides revoked + expired); checkbox opts into
|
||||
// showing everything for forensics. Revoke action mutates in place —
|
||||
// the row is refetched to update `is_revoked` badge.
|
||||
let sessions = $state<SessionSummary[]>([]);
|
||||
let sessionsError = $state<string | null>(null);
|
||||
let sessionsLoading = $state(false);
|
||||
let sessionsFilterUserId = $state<string>('');
|
||||
let sessionsIncludeRevoked = $state(false);
|
||||
let sessionRevokingId = $state<string | null>(null);
|
||||
|
||||
async function loadSessions() {
|
||||
sessionsLoading = true;
|
||||
sessionsError = null;
|
||||
try {
|
||||
const page = await listAdminSessions({
|
||||
userId: sessionsFilterUserId || undefined,
|
||||
includeRevoked: sessionsIncludeRevoked,
|
||||
limit: PAGE_SIZE
|
||||
});
|
||||
sessions = page.sessions;
|
||||
} catch (e) {
|
||||
sessionsError = errorMessage(e);
|
||||
} finally {
|
||||
sessionsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRevokeSession(id: string) {
|
||||
if (
|
||||
!confirm(
|
||||
t(
|
||||
'admin.sessions.revoke_confirm',
|
||||
'Revoke this session? The next request from that browser will 401.'
|
||||
)
|
||||
)
|
||||
)
|
||||
return;
|
||||
sessionRevokingId = id;
|
||||
try {
|
||||
await revokeAdminSession(id);
|
||||
await loadSessions();
|
||||
} catch (e) {
|
||||
sessionsError = errorMessage(e);
|
||||
} finally {
|
||||
sessionRevokingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugins
|
||||
let plugins = $state<PluginInfo[]>([]);
|
||||
let pluginsAvailable = $state(true);
|
||||
@@ -1581,6 +1639,7 @@
|
||||
let loaded = $state<Record<Tab, boolean>>({
|
||||
dashboard: false,
|
||||
users: false,
|
||||
sessions: false,
|
||||
drives: false,
|
||||
mounts: false,
|
||||
plugins: false,
|
||||
@@ -1595,6 +1654,7 @@
|
||||
loaded[tab] = true;
|
||||
if (tab === 'dashboard') void loadDashboard();
|
||||
else if (tab === 'users') void loadUsers();
|
||||
else if (tab === 'sessions') void loadSessions();
|
||||
else if (tab === 'drives') void loadDrivesTab();
|
||||
else if (tab === 'mounts') void loadMounts();
|
||||
else if (tab === 'plugins') void loadPlugins();
|
||||
@@ -2898,6 +2958,135 @@
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if tab === 'sessions'}
|
||||
<section class="admin-section" data-testid="admin-sessions-section">
|
||||
<h2>{t('admin.sessions.title', 'Sessions')}</h2>
|
||||
<p class="muted">
|
||||
{t(
|
||||
'admin.sessions.help',
|
||||
'Active sign-in sessions across all users. A locked icon means the session is bound to a browser keypair (DPoP) — a stolen cookie alone cannot use it. Revoke to force the browser to re-authenticate on its next request.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div class="admin-toolbar">
|
||||
<label>
|
||||
{t('admin.sessions.filter_user', 'User (UUID)')}:
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="00000000-…"
|
||||
data-testid="admin-sessions-user-filter-input"
|
||||
bind:value={sessionsFilterUserId}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="admin-sessions-include-revoked-checkbox"
|
||||
bind:checked={sessionsIncludeRevoked}
|
||||
/>
|
||||
{t('admin.sessions.include_revoked', 'Include revoked / expired')}
|
||||
</label>
|
||||
<button
|
||||
class="btn"
|
||||
data-testid="admin-sessions-refresh-btn"
|
||||
onclick={() => void loadSessions()}
|
||||
disabled={sessionsLoading}
|
||||
>
|
||||
{sessionsLoading
|
||||
? t('common.loading', 'Loading…')
|
||||
: t('admin.sessions.refresh', 'Refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if sessionsError}
|
||||
<div class="alert alert-error" data-testid="admin-sessions-error">
|
||||
{sessionsError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="admin-table" data-testid="admin-sessions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin.sessions.col_user', 'User')}</th>
|
||||
<th>{t('admin.sessions.col_created', 'Created')}</th>
|
||||
<th>{t('admin.sessions.col_expires', 'Expires')}</th>
|
||||
<th>{t('admin.sessions.col_ip', 'IP')}</th>
|
||||
<th>{t('admin.sessions.col_user_agent', 'User agent')}</th>
|
||||
<th>{t('admin.sessions.col_bound', 'Bound')}</th>
|
||||
<th>{t('admin.sessions.col_status', 'Status')}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sessions as s (s.id)}
|
||||
<tr
|
||||
data-testid={`admin-sessions-row-${s.id}`}
|
||||
class:muted={!s.is_active}
|
||||
>
|
||||
<td class="mono" title={s.user_id}>{s.user_id.slice(0, 8)}…</td>
|
||||
<td>{new Date(s.created_at).toLocaleString()}</td>
|
||||
<td>{new Date(s.expires_at).toLocaleString()}</td>
|
||||
<td class="mono">{s.ip_address ?? '—'}</td>
|
||||
<td class="truncate" title={s.user_agent ?? ''}>
|
||||
{shortUserAgent(s.user_agent)}
|
||||
</td>
|
||||
<td>
|
||||
{#if s.is_bound}
|
||||
<span
|
||||
title={t(
|
||||
'admin.sessions.bound_tooltip',
|
||||
{ prefix: s.dpop_jkt_prefix ?? '' },
|
||||
'DPoP-bound (jkt {{prefix}}…)'
|
||||
)}
|
||||
>
|
||||
🔒 {s.dpop_jkt_prefix ?? ''}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="muted">{t('admin.sessions.unbound', 'unbound')}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if s.is_revoked}
|
||||
<span class="badge badge-danger">
|
||||
{t('admin.sessions.revoked', 'revoked')}
|
||||
</span>
|
||||
{:else if !s.is_active}
|
||||
<span class="badge">{t('admin.sessions.expired', 'expired')}</span>
|
||||
{:else}
|
||||
<span class="badge badge-ok">
|
||||
{t('admin.sessions.active', 'active')}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if !s.is_revoked}
|
||||
<button
|
||||
class="btn btn-danger btn-sm"
|
||||
data-testid={`admin-sessions-revoke-btn-${s.id}`}
|
||||
onclick={() => void onRevokeSession(s.id)}
|
||||
disabled={sessionRevokingId === s.id}
|
||||
>
|
||||
{sessionRevokingId === s.id
|
||||
? t('common.working', 'Working…')
|
||||
: t('admin.sessions.revoke', 'Revoke')}
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if sessions.length === 0 && !sessionsLoading}
|
||||
<tr>
|
||||
<td colspan="8" class="muted">
|
||||
{t('admin.sessions.empty', 'No sessions match the current filter.')}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{:else if tab === 'mounts'}
|
||||
<section class="admin-section" data-testid="admin-mounts-section">
|
||||
<h2>{t('admin.mounts.title', 'External File Mounts')}</h2>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user