Specs v2
Tags: #security #sessions
GLPI version: 12.0+
Overview
GLPI has no way to see active sessions or terminate them remotely. If an account is compromised or a device is lost, there is no targeted response.
This feature adds persistent tracking of web sessions and OAuth tokens, an admin interface with audit trail, and a user self-service panel.
Scope
In — web sessions (PHP file-based), OAuth 2.0 access/refresh tokens, connection history, admin UI, user self-service, REST API, CLI, Setup → Security menu restructure.
Out — OAuth client management (already in Administration → OAuth Clients), legacy personal API tokens (personal_token in glpi_users), suspicious session detection (IP/User-Agent change mid-session — deferred to a later phase).
Personas & User Stories
Super-Admin — all sessions, all entities
ID | Story | Priority |
|---|---|---|
SA-01 | View all active sessions (browser + API) in a single list with type badges | Must |
SA-02 | See per session: user, type, IP or client+scopes, device, last activity, auth method | Must |
SA-03 | Filter by user, IP, entity, type, auth type, date range | Must |
SA-04 | Revoke any specific session or token | Must |
SA-05 | Revoke all sessions for a user | Must |
SA-06 | Revoke all sessions globally (excluding own) | Must |
SA-07 | From a user's form, view that user's active sessions and history (same page as U-01–03) | Should |
SA-08 | Every revocation recorded in the audit log | Must |
Admin — same as Super-Admin, scoped to accessible entity tree
Regular User — own sessions only
ID | Story | Priority |
|---|---|---|
U-01 | View all active sessions and API tokens; current session clearly identified | Must |
U-02 | Revoke any session or token (not current browser session) | Must |
U-03 | View my connection history with end reason | Should |
SA-07 and U-01–03 are served by the same Sessions tab on the User form — visible to the user themselves and to admins. Revoke buttons respect the same access rules in both contexts.
Why allow user self-service? Industry standard (GitHub, Google); GDPR Article 15 right of access; reduces admin load; users often detect unauthorised access first.
CLI
ID | Story | Priority |
|---|---|---|
C-01 | List active sessions | Must |
C-02 | Revoke sessions for a user or globally | Must |
Security Menu Restructure
Password policy and 2FA currently live inside Setup → General → Security tab. They move to a new first-level item:
Setup
├── General (Security tab removed)
├── Security ← new
│ ├── Password Policy (moved)
│ ├── Two-Factor Auth (moved)
│ └── Sessions (new)
└── …No functional change to Password Policy or 2FA — settings-only reorganisation. Requires a new SecurityConfig class implementing getMenuContent(), extracted template partials, and updated breadcrumbs.
Data Model
glpi_user_sessions — active sessions only
Rows are deleted (not flagged) when a session ends for any reason.
Column | Type | Notes |
|---|---|---|
| int PK | |
| int FK | |
| varchar(64) UNIQUE |
|
| varchar(261) NULL |
|
| varchar(45) | IPv4 or IPv6 |
| text | Raw — parsed lazily at display time |
| tinyint |
|
| timestamp | |
| timestamp | Updated on every request |
Indexes: |
Why store
session_file? The raw session ID cannot be reconstructed from its hash, so we store the filename directly tounlink()on revocation. If deletion fails,cronSession()cleans it up on the next run — an acceptable short-lived gap. Bothfilesandredisbackends are supported; see the Redis Session Backend section. v2 path: switch to a DB-backed\SessionHandlerInterfaceand drop this column (~1–2 weeks; main challenge is session locking for concurrent AJAX viaSELECT … FOR UPDATE).
glpi_user_session_history — immutable audit log
Inserted at login. Rows are kept indefinitely.
Column | Type | Notes |
|---|---|---|
| int PK | |
| int FK | |
| varchar(64) | Correlation key — indexed, not a FK |
| varchar(45) | |
| varchar(512) | Raw, truncate before inserting |
| tinyint | |
| timestamp | |
| timestamp NULL | |
| enum NULL |
|
| int NULL FK | Set when |
At session end: identify the history row by session_token_hash, then write logged_out_at, logout_reason, and users_id_revoked_bybefore deleting the active row.
Indexes: session_token_hash, (users_id, logged_in_at DESC).
Business Logic
Session lifecycle
-- Login (Session::init)
→ INSERT glpi_user_sessions
→ INSERT glpi_user_session_history (logged_out_at = NULL)
-- Every authenticated request
→ UPDATE glpi_user_sessions SET last_activity_at = NOW() WHERE session_token_hash = ?
-- Normal logout (Session::destroy)
→ DELETE FROM glpi_user_sessions WHERE session_token_hash = ?
→ UPDATE glpi_user_session_history SET logged_out_at = NOW(), logout_reason = 'user'
WHERE session_token_hash = ?
-- Admin / user revoke (SessionTracker::revokeSession)
-- → Assert target ≠ caller's current browser session
-- → Browser: destroySessionStorage($session_file) (see Redis section)
DELETE FROM glpi_user_sessions WHERE session_token_hash = ?
UPDATE glpi_user_session_history SET logged_out_at = NOW(),
logout_reason = 'admin', users_id_revoked_bybefore = ?
WHERE session_token_hash = ?
-- cookie_token is a single token shared across all devices for a given user.
-- Admin revocation bypasses the normal logout flow, so without an explicit clear
-- the target user would be silently re-authenticated via cookie on the next request.
UPDATE glpi_users SET cookie_token = NULL, cookie_token_date = NULL
WHERE id = <target users_id>
-- → API: AccessTokenRepository::revokeAccessToken() + cascade refresh tokens
-- → Write audit log entry (target user, acting user, session details, timestamp)
Per-request update
On every authenticated request, a single fire-and-forget UPDATE refreshes last_activity_at:
UPDATE glpi_user_sessions
SET last_activity_at = NOW()
WHERE session_token_hash = ?
User-Agent — lazy resolution
Raw User-Agent strings are stored at login. Browser name and OS are parsed with matomo/device-detector at render time only — never in the request path.
Cron — extend CronTask::cronSession()
The existing Clean expired sessions action works purely at the filesystem level (glob + delete by age). The file deletion step is kept unchanged. A separate DB pass is added after it; Find and delete glpi_user_sessions rows where last_activity_at < NOW() - INTERVAL <gc_maxlifetime> SECOND; update corresponding history rows with logout_reason = 'expired'. This covers sessions that were missed by the file cleanup (e.g. due to session.save_handler misconfiguration) or where the file was deleted but the DB row was not.
UI
Unified session list
Active sessions and connection history share a single table used in both the admin view and the user tab. Rows from glpi_user_sessions (active) and glpi_user_session_history (ended) are merged and sorted by login time descending. A Status column differentiates them.
Column | Content |
|---|---|
Type |
|
User (admin only) | Linked username |
Details | Browser + OS (parsed at render time) for browser sessions; client name · scopes for API tokens |
IP Address | For browser sessions only; — for API tokens |
Login | Relative date (with absolute on hover) for less than 24h sessions |
Last activity | same |
Status |
|
Actions | Revoke button for active sessions; empty for ended sessions |
The revoke button reads data-type from the row and calls the appropriate endpoint — both use the same glpi_confirm() → AJAX → remove row → toast flow.
Administration → Sessions
Access: Super-Admins only (Config:UPDATE right), scoped to their entity tree.
Single page with the unified list and a classic GLPI Search form.
Default criteria: Status = Active only. Switching to All reveals ended sessions from glpi_user_session_history interleaved by login date.
Criteria:
User search,
Status (Active only / All), by detault Active only
Type (Browser / API)
IP address
Date range.
Small mockup to illustrate the concept — final design can differ.
User form → Sessions tab
Added to src/User.php. Visible in two contexts:
Administration → Users → [user] — admin views sessions and history for that user (SA-07)
My Settings — user views their own sessions (U-01–03)
Single unified list filtered to this user; no User column. Active and ended sessions are shown together with no filter — the user sees their full history at a glance. Current browser session row shows a "This device" badge with no Revoke button.
Header action: Sign out all sessions (admin) or Sign out all other sessions (self) — operates on active rows only.
Small mockup to illustrate the concept — final design can differ.
REST API
Bearer token auth on /api.php/vx.y/.
These endpoints are proposed — discuss before implementing whether REST exposure is needed in v1. This is not mandatory for the current scope of work, but it could be needed for usage of alternative clients (e.g. mobile) or third-party integrations. The API should be designed to mirror the UI capabilities as closely as possible, with the same access control rules.
Method | Path | Description | Rights |
|---|---|---|---|
|
| List active browser sessions | Admin |
|
| Revoke browser session | Admin or own (not current) |
|
| Revoke all sessions for user | Admin |
|
| Active sessions + tokens | Self or Admin |
|
| Connection history | Self or Admin |
CLI
Command | Options | Description |
|---|---|---|
|
| List active sessions |
|
| Revoke sessions |
--force skips confirmation. --all excludes the caller's current session. Exit codes: 0 success · 1 error · 2 aborted.
Redis Session Backend
Both files and redis session backends are supported. The revocation mechanism is encapsulated in SessionTracker::destroySessionStorage(string $session_file): void so the rest of the flow stays unchanged.
Key derivation
The session_file column stores the PHP session filename (sess_<session_id>). The raw session ID is extracted by stripping the sess_ prefix: $session_id = substr($session_file, 5). This is the key used directly in Redis — no prefix.
Implementation
private static function destroySessionStorage(string $session_file): void
{
$session_id = substr($session_file, 5); // strip 'sess_'
if (ini_get('session.save_handler') === 'redis') {
$redis = new \Redis(/* connection parsed from ini_get('session.save_path') */);
$redis->del($session_id);
return;
}
@unlink(GLPI_SESSION_DIR . '/' . $session_file);
}Cron task
The existing filesystem glob (glob(GLPI_SESSION_DIR . '/sess_*')) has no effect on Redis. PHP's Redis session handler sets an EXPIRE TTL on each key automatically (based on session.gc_maxlifetime), so storage-level expiration is handled natively. The DB reconciliation pass in cronSession() runs identically for both backends.
System requirements check
A non-blocking warning is shown in Administration → System when session.save_handler is set to anything other than files or redis, as those are the only two supported backends:
Session save handler is set to
<value>. Onlyfilesandredisare supported. Session revocation may not work correctly.
Security
Token storage — session_token_hash = hash('sha256', session_id()). session_file stores only the filename; DB compromise already implies full access, so the incremental risk is marginal.
Access control — users read/revoke only their own sessions; nobody can revoke their own current browser session;
Remember-me on revocation — glpi_users.cookie_token is a single token per user (not per device). Clearing it on revocation invalidates remember-me on all the user's devices simultaneously — an acceptable trade-off given the current architecture. See the Session lifecycle for the exact steps at login, logout, and revocation.
CSRF — UI revoke actions use GLPI's existing CSRF token. API is stateless.
Audit trail — every revocation writes a GLPI Log entry: target user, acting user, session details, timestamp.
Data retention — IP addresses are personal data. History is kept indefinitely; administrators should review this against applicable privacy regulations and purge manually if required.
Non-file session backends — See the dedicated section below.