# 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 |
|--------|------|-------|
| `id`   | int PK |       |
| `users_id` | int FK `glpi_users` |       |
| `session_token_hash` | varchar(64) UNIQUE | `hash('sha256', session_id())` |
| `session_file` | varchar(261) NULL | `sess_<id>` filename, stored for `unlink()` on revocation |
| `ip_address` | varchar(45) | IPv4 or IPv6 |
| `user_agent` | text | Raw — parsed lazily at display time |
| `auth_type` | tinyint | `Auth` class constants |
| `created_at` | timestamp |       |
| `last_activity_at` | timestamp | Updated on every request |
| Indexes: `session_token_hash` (unique), `users_id`, `last_activity_at`. |      |       |

> **Why store** `**session_file**`**?** The raw session ID cannot be reconstructed from its hash, so we store the filename directly to `unlink()` on revocation. If deletion fails, `cronSession()` cleans it up on the next run — an acceptable short-lived gap. Both `files` and `redis` backends are supported; see the *Redis Session Backend* section. v2 path: switch to a DB-backed `\SessionHandlerInterface` and drop this column (\~1–2 weeks; main challenge is session locking for concurrent AJAX via `SELECT … FOR UPDATE`).

  
`**glpi_user_session_history**` **— immutable audit log**

Inserted at login. Rows are kept indefinitely.

| Column | Type | Notes |
|--------|------|-------|
| `id`   | int PK |       |
| `users_id` | int FK |       |
| `session_token_hash` | varchar(64) | Correlation key — indexed, not a FK |
| `ip_address` | varchar(45) |       |
| `user_agent` | varchar(512) | Raw, truncate before inserting |
| `auth_type` | tinyint |       |
| `logged_in_at` | timestamp |       |
| `logged_out_at` | timestamp NULL |       |
| `logout_reason` | enum NULL | `user` / `admin` / `expired` / `forced` |
| `revoked_by_users_id` | int NULL FK `glpi_users` | Set when `logout_reason = 'admin'` |

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**

```sql
-- 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`:

```sql
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   | `Browser` or `API` badge |
| 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 | `Active` badge for active sessions (API tokens also show the expiry date); logout reason badge (`User logout` / `Admin revoke` / `Expired`) for ended sessions |
| 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.* 

 ![](https://outline.teclib.com/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzLzFjOTk2NzhkLTIwNTctNGY5MC04MDY4LWFlOGMwYTU0N2YyZi9iMjE2ODAwZC0yMTg3LTRjZWYtODEzZi02ZDM4NzM2NGY5MTUvaW1hZ2UucG5nIiwidHlwZSI6ImF0dGFjaG1lbnQiLCJpYXQiOjE3OTQ1OTY0MDAsImV4cCI6MTc5NDY4MjgwMH0.NO99RrWJy7WTBFHS4VgGKFTMJmC5lsHAOcVbdWmrjd4 " =1003x878")

  
**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.* 

 ![](https://outline.teclib.com/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzLzFjOTk2NzhkLTIwNTctNGY5MC04MDY4LWFlOGMwYTU0N2YyZi8yYjMyZDAyNi1kZmUzLTQyYzItODMyMC0yNDY3OWU5YjY1MWEvaW1hZ2UucG5nIiwidHlwZSI6ImF0dGFjaG1lbnQiLCJpYXQiOjE3OTQ1OTY0MDAsImV4cCI6MTc5NDY4MjgwMH0.71c4of02ALIlIjrk9BcoLwj41L7ZrCUL5KH5CiiBHRc " =954x451")

## 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 |
|--------|------|-------------|--------|
| `GET`  | `/SessionManager/` | List active browser sessions | Admin  |
| `DELETE` | `/SessionManager/{id}` | Revoke browser session | Admin or own (not current) |
| `DELETE` | `/SessionManager/user/{users_id}` | Revoke all sessions for user | Admin  |
| `GET`  | `/User/{id}/Sessions` | Active sessions + tokens | Self or Admin |
| `GET`  | `/User/{id}/SessionHistory` | Connection history | Self or Admin |


## CLI

| Command | Options | Description |
|---------|---------|-------------|
| `glpi:session:list` | `--user=<name\|id>` | List active sessions |
| `glpi:session:revoke` | `--session=<id>` \| `--user=<name\|id>` \| `--all`, `--force` | 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**

```php
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>*`*. Only* `*files*` *and* `*redis*` *are 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.

---

**Documents**

- [Specs v2](https://outline.teclib.com/s/223237e6-7162-4082-a9cd-ae6e030ac2a0/doc/specs-v2-NOCAEm4v4e)
- [Specs v1](https://outline.teclib.com/s/223237e6-7162-4082-a9cd-ae6e030ac2a0/doc/specs-v1-ZWEsXWd2Vp)