strapi-security-suite
Version:
All-in-one authentication and session security plugin for Strapi v5
428 lines (332 loc) โข 19.4 kB
Markdown
# ๐ก๏ธ 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."_