# 🛡️ Strapi Security Suite

### The admin security plugin that takes your sessions _personally_ — and now scales horizontally.

> **One plugin. Auto-logout. Single-session enforcement. Token revocation. Heartbeat. Multi-pod-safe.**
> Built for **Strapi v5**. Backed by your existing database. Zero new infrastructure.

---

## 🤔 What Is This?

Imagine a bouncer at a nightclub. But the nightclub is your **Strapi admin panel**, the bouncer has a _perfect memory_, never sleeps, and will physically escort your idle admins out the door after 30 minutes of doing nothing.

**That's this plugin.** And in v0.4, the bouncer works across every door of every venue in the franchise — not just the one he's standing at.

```
  🔐 Admin logs in (Pod A)
   |
   |  👀 Activity tracked → DB (visible to all pods)
   |  🫀 Client heartbeat fires every 30s on mouse/keyboard
   |
   |  😴 Admin walks away from desk...
   |
   |  ⏰ 30 minutes pass...
   |
   |  👑 Watcher leader (could be Pod C) marks session revoked → DB
   |
   🚪 Next request to ANY pod → BOOM. Logged out. Cookies cleared. Token dead.
         No coin-flip. No "depends which pod you hit." Just security.
```

---

## ✨ Features at a Glance

| Feature                    | What It Does                                               | Vibe                 |
| -------------------------- | ---------------------------------------------------------- | -------------------- |
| ⏰ **Auto-Logout**         | Kicks idle admins after configurable minutes               | "Use it or lose it"  |
| 🫀 **Activity Heartbeat**  | Form-filling counts as activity (no spurious idle-logouts) | "We see you typing"  |
| 🚫 **Single-Session Lock** | One admin = one session. Across every pod.                 | "No shadow clones"   |
| 💀 **Session Revocation**  | Per-`sessionId`. Cluster-wide. Instant.                    | "Ghosts get ghosted" |
| 🌐 **Multi-Pod Safe**      | DB-backed state, leader-elected watcher                    | "OpenShift-ready"    |
| 🔑 **Password Policy**     | Expiry + non-reusable passwords (configurable)             | "Rotate or regret"   |
| ⚙️ **Admin UI**            | Settings panel right inside Strapi                         | "Click, don't code"  |
| 🛡️ **Input Validation**    | Server-side validation on every settings save              | "Trust nobody"       |

---

## 🚀 Quick Start

### Step 1: Install it

```bash
yarn add strapi-security-suite
```

### Step 2: Enable it

Add this to your `config/plugins.js` (or `.ts`):

```javascript
module.exports = ({ env }) => ({
  'strapi-security-suite': {
    enabled: true,
  },
});
```

### Step 3: Restart Strapi

```bash
yarn develop
```

Three new tables — `sss_admin_sessions`, `sss_login_locks`, `sss_watcher_leases` — are auto-created by Strapi on first boot. No manual migration. No new dependencies. No config required.

### Step 4: Find it

Go to **Settings** → **Global** → **Security Suite**

That's it. You're done. Go get a coffee. ☕

---

## 🌐 Why Multi-Pod-Safe Matters

In v0.3 the plugin kept its session state — last-active timestamps, revoked emails, login locks — in **per-pod in-memory `Map`s and `Set`s**. On a single-pod deployment, fine. On a horizontally-scaled OpenShift / Kubernetes deployment with multiple replicas behind a load balancer, those data structures **lived independently per pod** and that broke every guarantee the plugin made:

- An admin's requests round-robined across N pods → each pod saw only ~1/N of their activity → some pod's watcher decided they'd been idle 30 min and revoked them while they were actively typing.
- Pod A revoked a session. The next request landed on Pod B → no entry → no force-reload signal → revocation became a 1/N-probability event.
- A logged-out admin's bearer kept working on every pod that hadn't seen the logout, until the JWT expired.
- Two concurrent logins for the same email hit different pods → both pods saw empty maps → both succeeded. Single-session enforcement only worked on a single pod, which is exactly when you don't need it.

**v0.4 moves all of that state into the database.** Revocation issued on any pod is visible to every other pod on the next request. The watcher is leader-elected so only one pod cluster-wide actually runs the 5-second tick. Login locks are atomic across pods via `SELECT … FOR UPDATE`. No Redis. No new infra. Your existing Postgres / MySQL / SQLite handles it.

---

## 🖼️ The Admin Panel

Once installed, you get a beautiful settings page with two panels:

```
┌───────────────────────────────────────────────────────┐
│            🛡️ Security & Session Settings              │
├───────────────────────────┬───────────────────────────┤
│                           │                           │
│  🕐 SESSION MANAGEMENT    │  🔑 PASSWORD MANAGEMENT    │
│                           │                           │
│  Auto Logout Time: [30]   │  Password Control: [ON]   │
│  (minutes)                │                           │
│                           │  Expiry Days: [30]        │
│  Multi-Session            │                           │
│  Control: [ON]            │  Non-Reusable: [ON]       │
│                           │                           │
├───────────────────────────┴───────────────────────────┤
│                               [ 💾 Save Settings ]    │
└───────────────────────────────────────────────────────┘
```

Settings are stored in a single-type DB record. Change a value, hit save, it takes effect immediately. No restarts. No config files.

---

## 🧠 How It Actually Works

### 🔗 The Middleware Pipeline

When any request hits your Strapi server, it passes through **5 security checkpoints** (middlewares), in this exact order:

```
  🌐 Incoming Request
       │
       ▼
  1. 🐣 seedUserInfos
  │    "Decode the JWT. Pull userId AND sessionId. Hydrate ctx.state."
  │
  ▼
  2. 🔍 interceptRenewToken
  │    "Logging out? Mark this sessionId revoked in the DB. Cluster-wide."
  │
  ▼
  3. 👣 trackActivity
  │    "If this sessionId is revoked → 403 + clear cookies. Else stamp lastActiveAt
  │     to the DB (write-coalesced to once per 30s)."
  │
  ▼
  4. ☠️ rejectRevokedTokens
  │    "Belt-and-suspenders revocation check. Sets app.admin.tk header so the
  │     frontend force-reloads. Calls sessionManager.invalidateRefreshToken."
  │
  ▼
  5. 🚫 preventMultipleSessions  (on login only)
       "Acquire cross-pod login lock. Refuse with 409 if another active session
        for this email exists anywhere in the cluster."
```

### ⏱️ The Auto-Logout Watcher (Leader-Elected)

Every pod runs a `setInterval` every 5 seconds. Inside the tick:

```
  🔄 Every 5 seconds, every pod:
     │
     ├─→ acquireWatcherLease()  (atomic UPDATE on sss_watcher_leases)
     │     │
     │     ├─→ Got it? I'm the leader. Continue.
     │     └─→ Someone else has it? Skip the rest. (1 cheap DB query, done.)
     │
     │  (Only the leader runs the body below)
     │
     ├─→ pruneExpiredLocks()   (clean up sss_login_locks where lockedUntil < now)
     │
     ├─→ listIdleSessions({ idleThresholdMs })
     │     SELECT FROM sss_admin_sessions
     │     WHERE revoked_at IS NULL AND last_active_at < (now - threshold)
     │
     └─→ For each idle session:
          • UPDATE sss_admin_sessions SET revoked_at = NOW() WHERE id = ?
          • sessionManager('admin').invalidateRefreshToken(userId)
          • Log it
```

If the leader pod dies, its lease (15s TTL) expires and another pod claims it on the next tick. Worst-case revocation lag during failover: 15 seconds.

### 🫀 The Activity Heartbeat

A new admin-side hook listens for `mousemove`, `keydown`, `scroll`, `click`, `touchstart` (passive). On any event, **throttled to once per 30 seconds**, it fires `POST /strapi-security-suite/heartbeat`. The middleware chain treats it like any other authenticated request, so `trackActivity` updates `lastActiveAt`.

This means a user filling a long form for 25 minutes — generating zero other HTTP traffic — is **not** auto-logged-out. Form-filling is correctly recognized as activity.

### 🖥️ The Frontend Interceptor

`window.fetch` is patched to watch for the `app.admin.tk` response header:

```
  🌐 Admin makes any API call
       │
       ▼
  👀 Check response headers for 'app.admin.tk'
       │
       YES → 🚨 FORCED LOGOUT 🚨  window.location.reload()
       │
       NO  → ✅ Normal response. Continue working.
```

---

## 🗃️ DB Schema (auto-created on boot)

| Table                | Purpose                       | Key columns                                                    |
| -------------------- | ----------------------------- | -------------------------------------------------------------- |
| `sss_admin_sessions` | One row per admin session     | `session_id` (unique), `email`, `last_active_at`, `revoked_at` |
| `sss_login_locks`    | Cross-pod login lock          | `email` (unique), `locked_until`                               |
| `sss_watcher_leases` | Watcher leader-election lease | `name` (unique), `holder`, `expires_at`                        |

Hidden from the content-manager and content-type-builder via `pluginOptions`. Strapi creates them on first boot the same way the existing `security-settings` singleType is created — no manual migration step.

---

## 📂 Project Structure

```
strapi-security-suite/
📁 admin/src/                    ← Admin panel (React)
│   📄 index.js                    Plugin entry + fetch interceptor + heartbeat install
│   📄 heartbeat.js                Throttled activity-heartbeat client hook
│   📄 constants.js                API paths, header names, heartbeat throttle
│   📄 pluginId.js                 Plugin ID constant
│   📁 components/Initializer.jsx  Plugin lifecycle init
│   📁 pages/
│   │   📄 App.jsx                 Router
│   │   📄 HomePage.jsx            Settings UI
│   📁 translations/en.json        i18n strings
│
📁 server/src/                   ← Server-side (Node.js)
│   📄 index.js                    Plugin entry point
│   📄 register.js                 Middleware registration phase
│   📄 bootstrap.js                Permissions + settings seeding + watcher start
│   📄 destroy.js                  Releases watcher lease, stops interval
│   📄 constants.js                ⭐ ALL magic values live here
│   │
│   📁 controllers/
│   │   📄 adminSecurityController.js   GET/POST settings + POST heartbeat
│   │
│   📁 services/
│   │   📄 state.js                The DB-backed state core (replaces the in-memory globals)
│   │   📄 autoLogoutChecker.js    Leader-elected background watcher
│   │
│   📁 middlewares/
│   │   📄 seedUserInfos.js        Decode JWT, extract userId + sessionId
│   │   📄 interceptRenewToken.js  Revoke session on logout (DB-backed)
│   │   📄 trackActivity.js        Persist lastActiveAt (write-coalesced)
│   │   📄 rejectRevokedTokens.js  Force-reload signal + cookie clear
│   │   📄 preventMultipleSessions.js  Cross-pod login lock + active-session check
│   │
│   📁 policies/has-admin-permission.js
│   │
│   📁 utils/
│   │   📄 errors.js               PluginError, ValidationError, AuthorizationError
│   │   📄 clearSessionCookies.js  Clears koa.sess, koa.sess.sig, refresh + JWT cookies
│   │
│   📁 content-types/
│   │   📁 security-settings/      Plugin config (singleType)
│   │   📁 admin-session/          Per-session activity + revocation
│   │   📁 login-lock/             Cross-pod login lock
│   │   📁 watcher-lease/          Watcher leader-election lease
│   │
│   📁 routes/index.js             Admin-typed routes with policies
│
📁 tests/                        ← Vitest test suite (66 tests)
│   📁 helpers/
│   │   📄 strapi-fake.js          sqlite :memory: + Knex harness
│   │   📄 mock-strapi.js          Mock-based ctx + state helpers
│   📁 server/
│   │   📄 state.test.js                  15 tests — touch, revocation, listIdle, hasActiveSession
│   │   📄 state.concurrency.test.js      9 tests — multi-pod login lock + watcher lease
│   │   📄 seedUserInfos.test.js          6 tests — JWT decode, ctx hydration
│   │   📄 trackActivity.test.js          4 tests — touch, revocation rejection
│   │   📄 rejectRevokedTokens.test.js    4 tests — header signal, sessionManager
│   │   📄 preventMultipleSessions.test.js  8 tests — login lock flow
│   │   📄 interceptRenewToken.test.js    3 tests — logout revocation
│   │   📄 autoLogoutChecker.test.js      8 tests — leader election + idle revocation
│   │   📄 adminSecurityController.test.js  9 tests — heartbeat + settings validation
```

---

## 🔧 Configuration Schema

All settings live in a **single-type** content-type in the database:

```json
{
  "autoLogoutTime": 30,
  "multipleSessionsControl": true,
  "passwordExpiryDays": 30,
  "nonReusablePassword": true,
  "enablePasswordManagement": true
}
```

| Field                      | Type      | Default | What It Does                             |
| -------------------------- | --------- | ------- | ---------------------------------------- |
| `autoLogoutTime`           | `integer` | `30`    | Minutes of inactivity before auto-logout |
| `multipleSessionsControl`  | `boolean` | `true`  | Block concurrent sessions for same admin |
| `passwordExpiryDays`       | `integer` | `30`    | Days before password must be changed     |
| `nonReusablePassword`      | `boolean` | `true`  | Prevent reuse of previous passwords      |
| `enablePasswordManagement` | `boolean` | `true`  | Master switch for password features      |

---

## 🧪 API Endpoints

All routes are **admin-typed** (Strapi handles auth automatically):

| Method | Path                                    | Auth     | Permission       | Description                       |
| ------ | --------------------------------------- | -------- | ---------------- | --------------------------------- |
| `POST` | `/strapi-security-suite/heartbeat`      | 🔒 Admin | —                | Activity keep-alive (returns 204) |
| `GET`  | `/strapi-security-suite/admin/settings` | 🔒 Admin | `view-configs`   | Read security settings            |
| `POST` | `/strapi-security-suite/admin/settings` | 🔒 Admin | `manage-configs` | Update security settings          |

### 🔐 Permissions

| Permission                                     | What It Allows           |
| ---------------------------------------------- | ------------------------ |
| `plugin::strapi-security-suite.access`         | Access the settings page |
| `plugin::strapi-security-suite.view-configs`   | Read security settings   |
| `plugin::strapi-security-suite.manage-configs` | Modify security settings |

---

## 💡 Recommended Host-App Configuration

The plugin works out of the box with Strapi defaults, but for **tighter revocation latency** consider lowering the access-token TTL in your host app's `config/admin.js`:

```javascript
module.exports = ({ env }) => ({
  auth: {
    secret: env('ADMIN_JWT_SECRET'),
    options: {
      expiresIn: '2m', // ← short access tokens, refreshed transparently by the admin frontend
    },
  },
});
```

With a 2-minute access-token TTL, a revoked admin loses access within ~2 minutes even if no other request is made (next refresh attempt fails because the refresh token is also invalidated). With the default 30-minute TTL, revocation is enforced on the next request the admin makes (via the `app.admin.tk` force-reload signal) — instant for active admins, up to 30 min for an idle one whose tab is open.

---

## 🛠️ Development

```bash
yarn install          # Install dependencies
yarn build            # Build the plugin
yarn watch            # Auto-rebuild on changes
yarn lint             # ESLint
yarn lint:fix         # ESLint --fix
yarn format           # Prettier
yarn format:check     # Check formatting
yarn verify           # Verify plugin exports
yarn test             # Run the full test suite (66 tests)
yarn test:watch       # Vitest in watch mode
yarn test:coverage    # Coverage report
```

The state-service tests run against a real sqlite `:memory:` DB via Knex — they exercise the actual SQL the plugin issues, including the `SELECT … FOR UPDATE` paths and `ON CONFLICT` behaviors. Two simulated pods cover the cross-pod concurrency cases.

---

## 🔮 Roadmap

| Feature                       | Status            |
| ----------------------------- | ----------------- |
| ⏰ Auto-Logout                | ✅ Shipped (v0.1) |
| 🚫 Single-Session Enforcement | ✅ Shipped (v0.1) |
| 💀 Session Revocation         | ✅ Shipped (v0.1) |
| ⚙️ Admin Settings UI          | ✅ Shipped (v0.1) |
| 🌐 **Multi-Pod-Safe State**   | ✅ Shipped (v0.4) |
| 🫀 **Activity Heartbeat**     | ✅ Shipped (v0.4) |
| 🧪 **Test Suite (66 tests)**  | ✅ Shipped (v0.4) |
| 🔑 Password Expiry            | 🚧 In Development |
| 🔄 Non-Reusable Passwords     | 🚧 In Development |
| 📝 Admin Activity Logs        | 🔜 Planned        |
| 📊 Security Dashboard         | 🔜 Planned        |
| 👊 Brute Force Detection      | 🔜 Planned        |

---

## 🗣️ Real Talk

> "We installed this and now our interns can't share logins anymore."
> — A CTO, probably

> "Our admin panel feels like it _judges_ us now. I love it."
> — That one developer who actually cares

> "I left my desk for coffee and came back logged out. Respect."
> — Someone who now understands security

> "We scaled to 8 pods on OpenShift and the plugin… just kept working. Sessions, revocations, locks — all consistent."
> — A platform engineer in v0.4

---

## 👥 Author

**[LPIX-11](mailto:mohamed.johnson@orange-sonatel.com)** — Orange / Sonatel

---

## ⚖️ License

**MIT** — Do whatever you want. Just don't blame us if you turn off all the features and get breached. That's on you.

---

## 💡 Philosophy

Security should be:

- **Correct under load** — Multi-pod deployments shouldn't degrade the security model into a coin flip.
- **Cheap to operate** — DB-backed state with write-coalescing. No Redis. No new infra.
- **Unforgiving** — Idle? Gone. Revoked? Dead. Duplicated? Blocked.
- **Mildly judgmental** — This plugin _will_ side-eye your stale sessions.

> _"The meta-principle: make the right thing the default thing. Discipline compounds. Shortcuts compound too, just in the wrong direction."_
