UNPKG

strapi-security-suite

Version:

All-in-one authentication and session security plugin for Strapi v5

428 lines (332 loc) โ€ข 19.4 kB
# ๐Ÿ›ก๏ธ 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."_