strapi-security-suite 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +171 -132
- package/dist/_chunks/{App-ConqHB2Q.js → App-B-CRozv4.js} +1 -1
- package/dist/_chunks/{App-CBOxzfqu.mjs → App-CM1kp54o.mjs} +1 -1
- package/dist/_chunks/{index-BGBd43He.js → index-BVsx1rse.js} +35 -3
- package/dist/_chunks/{index-ZKJuPZEH.mjs → index-CrITOuMT.mjs} +35 -3
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/server/index.js +580 -134
- package/dist/server/index.mjs +580 -134
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -1,43 +1,48 @@
|
|
|
1
1
|
# 🛡️ Strapi Security Suite
|
|
2
2
|
|
|
3
|
-
### The admin security plugin that takes your sessions _personally_.
|
|
3
|
+
### The admin security plugin that takes your sessions _personally_ — and now scales horizontally.
|
|
4
4
|
|
|
5
|
-
> **One plugin. Auto-logout. Single-session enforcement. Token revocation.
|
|
6
|
-
> Built for **Strapi v5**.
|
|
5
|
+
> **One plugin. Auto-logout. Single-session enforcement. Token revocation. Heartbeat. Multi-pod-safe.**
|
|
6
|
+
> Built for **Strapi v5**. Backed by your existing database. Zero new infrastructure.
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
## 🤔 What Is This?
|
|
11
11
|
|
|
12
|
-
Imagine a bouncer at a nightclub. But the nightclub is your **Strapi admin panel**,
|
|
12
|
+
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.
|
|
13
13
|
|
|
14
|
-
**That's this plugin.**
|
|
14
|
+
**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.
|
|
15
15
|
|
|
16
16
|
```
|
|
17
|
-
🔐 Admin logs in
|
|
17
|
+
🔐 Admin logs in (Pod A)
|
|
18
18
|
|
|
|
19
|
-
| 👀
|
|
19
|
+
| 👀 Activity tracked → DB (visible to all pods)
|
|
20
|
+
| 🫀 Client heartbeat fires every 30s on mouse/keyboard
|
|
20
21
|
|
|
|
21
|
-
| 😴 Admin
|
|
22
|
+
| 😴 Admin walks away from desk...
|
|
22
23
|
|
|
|
23
24
|
| ⏰ 30 minutes pass...
|
|
24
25
|
|
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
| 👑 Watcher leader (could be Pod C) marks session revoked → DB
|
|
27
|
+
|
|
|
28
|
+
🚪 Next request to ANY pod → BOOM. Logged out. Cookies cleared. Token dead.
|
|
29
|
+
No coin-flip. No "depends which pod you hit." Just security.
|
|
27
30
|
```
|
|
28
31
|
|
|
29
32
|
---
|
|
30
33
|
|
|
31
34
|
## ✨ Features at a Glance
|
|
32
35
|
|
|
33
|
-
| Feature | What It Does
|
|
34
|
-
| -------------------------- |
|
|
35
|
-
| ⏰ **Auto-Logout** | Kicks idle admins after configurable minutes
|
|
36
|
-
|
|
|
37
|
-
|
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
-
|
|
|
36
|
+
| Feature | What It Does | Vibe |
|
|
37
|
+
| -------------------------- | ---------------------------------------------------------- | -------------------- |
|
|
38
|
+
| ⏰ **Auto-Logout** | Kicks idle admins after configurable minutes | "Use it or lose it" |
|
|
39
|
+
| 🫀 **Activity Heartbeat** | Form-filling counts as activity (no spurious idle-logouts) | "We see you typing" |
|
|
40
|
+
| 🚫 **Single-Session Lock** | One admin = one session. Across every pod. | "No shadow clones" |
|
|
41
|
+
| 💀 **Session Revocation** | Per-`sessionId`. Cluster-wide. Instant. | "Ghosts get ghosted" |
|
|
42
|
+
| 🌐 **Multi-Pod Safe** | DB-backed state, leader-elected watcher | "OpenShift-ready" |
|
|
43
|
+
| 🔑 **Password Policy** | Expiry + non-reusable passwords (configurable) | "Rotate or regret" |
|
|
44
|
+
| ⚙️ **Admin UI** | Settings panel right inside Strapi | "Click, don't code" |
|
|
45
|
+
| 🛡️ **Input Validation** | Server-side validation on every settings save | "Trust nobody" |
|
|
41
46
|
|
|
42
47
|
---
|
|
43
48
|
|
|
@@ -67,6 +72,8 @@ module.exports = ({ env }) => ({
|
|
|
67
72
|
yarn develop
|
|
68
73
|
```
|
|
69
74
|
|
|
75
|
+
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.
|
|
76
|
+
|
|
70
77
|
### Step 4: Find it
|
|
71
78
|
|
|
72
79
|
Go to **Settings** → **Global** → **Security Suite**
|
|
@@ -75,6 +82,19 @@ That's it. You're done. Go get a coffee. ☕
|
|
|
75
82
|
|
|
76
83
|
---
|
|
77
84
|
|
|
85
|
+
## 🌐 Why Multi-Pod-Safe Matters
|
|
86
|
+
|
|
87
|
+
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:
|
|
88
|
+
|
|
89
|
+
- 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.
|
|
90
|
+
- 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.
|
|
91
|
+
- A logged-out admin's bearer kept working on every pod that hadn't seen the logout, until the JWT expired.
|
|
92
|
+
- 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.
|
|
93
|
+
|
|
94
|
+
**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.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
78
98
|
## 🖼️ The Admin Panel
|
|
79
99
|
|
|
80
100
|
Once installed, you get a beautiful settings page with two panels:
|
|
@@ -86,8 +106,8 @@ Once installed, you get a beautiful settings page with two panels:
|
|
|
86
106
|
│ │ │
|
|
87
107
|
│ 🕐 SESSION MANAGEMENT │ 🔑 PASSWORD MANAGEMENT │
|
|
88
108
|
│ │ │
|
|
89
|
-
│ Auto Logout Time: [30]
|
|
90
|
-
│ (minutes)
|
|
109
|
+
│ Auto Logout Time: [30] │ Password Control: [ON] │
|
|
110
|
+
│ (minutes) │ │
|
|
91
111
|
│ │ Expiry Days: [30] │
|
|
92
112
|
│ Multi-Session │ │
|
|
93
113
|
│ Control: [ON] │ Non-Reusable: [ON] │
|
|
@@ -97,14 +117,12 @@ Once installed, you get a beautiful settings page with two panels:
|
|
|
97
117
|
└───────────────────────────────────────────────────────┘
|
|
98
118
|
```
|
|
99
119
|
|
|
100
|
-
|
|
120
|
+
Settings are stored in a single-type DB record. Change a value, hit save, it takes effect immediately. No restarts. No config files.
|
|
101
121
|
|
|
102
122
|
---
|
|
103
123
|
|
|
104
124
|
## 🧠 How It Actually Works
|
|
105
125
|
|
|
106
|
-
Here's the whole flow, explained like you're five (but a very smart five):
|
|
107
|
-
|
|
108
126
|
### 🔗 The Middleware Pipeline
|
|
109
127
|
|
|
110
128
|
When any request hits your Strapi server, it passes through **5 security checkpoints** (middlewares), in this exact order:
|
|
@@ -114,47 +132,65 @@ When any request hits your Strapi server, it passes through **5 security checkpo
|
|
|
114
132
|
│
|
|
115
133
|
▼
|
|
116
134
|
1. 🐣 seedUserInfos
|
|
117
|
-
│ "
|
|
135
|
+
│ "Decode the JWT. Pull userId AND sessionId. Hydrate ctx.state."
|
|
118
136
|
│
|
|
119
137
|
▼
|
|
120
138
|
2. 🔍 interceptRenewToken
|
|
121
|
-
│ "
|
|
139
|
+
│ "Logging out? Mark this sessionId revoked in the DB. Cluster-wide."
|
|
122
140
|
│
|
|
123
141
|
▼
|
|
124
142
|
3. 👣 trackActivity
|
|
125
|
-
│ "
|
|
143
|
+
│ "If this sessionId is revoked → 403 + clear cookies. Else stamp lastActiveAt
|
|
144
|
+
│ to the DB (write-coalesced to once per 30s)."
|
|
126
145
|
│
|
|
127
146
|
▼
|
|
128
147
|
4. ☠️ rejectRevokedTokens
|
|
129
|
-
│ "
|
|
148
|
+
│ "Belt-and-suspenders revocation check. Sets app.admin.tk header so the
|
|
149
|
+
│ frontend force-reloads. Calls sessionManager.invalidateRefreshToken."
|
|
130
150
|
│
|
|
131
151
|
▼
|
|
132
152
|
5. 🚫 preventMultipleSessions (on login only)
|
|
133
|
-
"
|
|
153
|
+
"Acquire cross-pod login lock. Refuse with 409 if another active session
|
|
154
|
+
for this email exists anywhere in the cluster."
|
|
134
155
|
```
|
|
135
156
|
|
|
136
|
-
### ⏱️ The Auto-Logout Watcher
|
|
157
|
+
### ⏱️ The Auto-Logout Watcher (Leader-Elected)
|
|
137
158
|
|
|
138
|
-
|
|
159
|
+
Every pod runs a `setInterval` every 5 seconds. Inside the tick:
|
|
139
160
|
|
|
140
161
|
```
|
|
141
|
-
🔄 Every 5 seconds:
|
|
162
|
+
🔄 Every 5 seconds, every pod:
|
|
142
163
|
│
|
|
143
|
-
|
|
144
|
-
│ 🕐 Compare last activity timestamp vs. configured timeout
|
|
145
|
-
│
|
|
146
|
-
│ 😴 Idle too long?
|
|
147
|
-
│ │
|
|
148
|
-
│ YES → Add email to revoked set
|
|
149
|
-
│ │ → Delete session from activity map
|
|
150
|
-
│ │ → Log it: "Auto-logged out admin X after Y seconds"
|
|
164
|
+
├─→ acquireWatcherLease() (atomic UPDATE on sss_watcher_leases)
|
|
151
165
|
│ │
|
|
152
|
-
│
|
|
166
|
+
│ ├─→ Got it? I'm the leader. Continue.
|
|
167
|
+
│ └─→ Someone else has it? Skip the rest. (1 cheap DB query, done.)
|
|
168
|
+
│
|
|
169
|
+
│ (Only the leader runs the body below)
|
|
170
|
+
│
|
|
171
|
+
├─→ pruneExpiredLocks() (clean up sss_login_locks where lockedUntil < now)
|
|
172
|
+
│
|
|
173
|
+
├─→ listIdleSessions({ idleThresholdMs })
|
|
174
|
+
│ SELECT FROM sss_admin_sessions
|
|
175
|
+
│ WHERE revoked_at IS NULL AND last_active_at < (now - threshold)
|
|
176
|
+
│
|
|
177
|
+
└─→ For each idle session:
|
|
178
|
+
• UPDATE sss_admin_sessions SET revoked_at = NOW() WHERE id = ?
|
|
179
|
+
• sessionManager('admin').invalidateRefreshToken(userId)
|
|
180
|
+
• Log it
|
|
153
181
|
```
|
|
154
182
|
|
|
183
|
+
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.
|
|
184
|
+
|
|
185
|
+
### 🫀 The Activity Heartbeat
|
|
186
|
+
|
|
187
|
+
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`.
|
|
188
|
+
|
|
189
|
+
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.
|
|
190
|
+
|
|
155
191
|
### 🖥️ The Frontend Interceptor
|
|
156
192
|
|
|
157
|
-
|
|
193
|
+
`window.fetch` is patched to watch for the `app.admin.tk` response header:
|
|
158
194
|
|
|
159
195
|
```
|
|
160
196
|
🌐 Admin makes any API call
|
|
@@ -162,79 +198,89 @@ On the admin panel side, `window.fetch` is patched to watch for a special header
|
|
|
162
198
|
▼
|
|
163
199
|
👀 Check response headers for 'app.admin.tk'
|
|
164
200
|
│
|
|
165
|
-
YES → 🚨 FORCED LOGOUT 🚨
|
|
166
|
-
│ window.stop()
|
|
167
|
-
│ window.location.reload()
|
|
168
|
-
│ (Session is over. Go home.)
|
|
201
|
+
YES → 🚨 FORCED LOGOUT 🚨 window.location.reload()
|
|
169
202
|
│
|
|
170
203
|
NO → ✅ Normal response. Continue working.
|
|
171
204
|
```
|
|
172
205
|
|
|
173
206
|
---
|
|
174
207
|
|
|
208
|
+
## 🗃️ DB Schema (auto-created on boot)
|
|
209
|
+
|
|
210
|
+
| Table | Purpose | Key columns |
|
|
211
|
+
| -------------------- | ----------------------------- | -------------------------------------------------------------- |
|
|
212
|
+
| `sss_admin_sessions` | One row per admin session | `session_id` (unique), `email`, `last_active_at`, `revoked_at` |
|
|
213
|
+
| `sss_login_locks` | Cross-pod login lock | `email` (unique), `locked_until` |
|
|
214
|
+
| `sss_watcher_leases` | Watcher leader-election lease | `name` (unique), `holder`, `expires_at` |
|
|
215
|
+
|
|
216
|
+
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.
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
175
220
|
## 📂 Project Structure
|
|
176
221
|
|
|
177
222
|
```
|
|
178
223
|
strapi-security-suite/
|
|
179
224
|
📁 admin/src/ ← Admin panel (React)
|
|
180
|
-
│ 📄 index.js Plugin entry + fetch interceptor
|
|
181
|
-
│ 📄
|
|
225
|
+
│ 📄 index.js Plugin entry + fetch interceptor + heartbeat install
|
|
226
|
+
│ 📄 heartbeat.js Throttled activity-heartbeat client hook
|
|
227
|
+
│ 📄 constants.js API paths, header names, heartbeat throttle
|
|
182
228
|
│ 📄 pluginId.js Plugin ID constant
|
|
183
|
-
│ 📁 components/
|
|
184
|
-
│ │ 📄 Initializer.jsx Plugin lifecycle init
|
|
229
|
+
│ 📁 components/Initializer.jsx Plugin lifecycle init
|
|
185
230
|
│ 📁 pages/
|
|
186
231
|
│ │ 📄 App.jsx Router
|
|
187
|
-
│ │ 📄 HomePage.jsx Settings UI
|
|
188
|
-
│ 📁 translations/
|
|
189
|
-
│ 📄 en.json i18n strings
|
|
232
|
+
│ │ 📄 HomePage.jsx Settings UI
|
|
233
|
+
│ 📁 translations/en.json i18n strings
|
|
190
234
|
│
|
|
191
235
|
📁 server/src/ ← Server-side (Node.js)
|
|
192
236
|
│ 📄 index.js Plugin entry point
|
|
193
237
|
│ 📄 register.js Middleware registration phase
|
|
194
|
-
│ 📄 bootstrap.js Permissions + settings seeding
|
|
195
|
-
│ 📄 destroy.js
|
|
238
|
+
│ 📄 bootstrap.js Permissions + settings seeding + watcher start
|
|
239
|
+
│ 📄 destroy.js Releases watcher lease, stops interval
|
|
196
240
|
│ 📄 constants.js ⭐ ALL magic values live here
|
|
197
241
|
│ │
|
|
198
242
|
│ 📁 controllers/
|
|
199
|
-
│ │ 📄 adminSecurityController.js GET/POST settings
|
|
243
|
+
│ │ 📄 adminSecurityController.js GET/POST settings + POST heartbeat
|
|
200
244
|
│ │
|
|
201
245
|
│ 📁 services/
|
|
202
|
-
│ │ 📄
|
|
246
|
+
│ │ 📄 state.js The DB-backed state core (replaces the in-memory globals)
|
|
247
|
+
│ │ 📄 autoLogoutChecker.js Leader-elected background watcher
|
|
203
248
|
│ │
|
|
204
249
|
│ 📁 middlewares/
|
|
205
|
-
│ │ 📄 seedUserInfos.js
|
|
206
|
-
│ │ 📄 interceptRenewToken.js
|
|
207
|
-
│ │ 📄 trackActivity.js
|
|
208
|
-
│ │ 📄 rejectRevokedTokens.js
|
|
209
|
-
│ │ 📄 preventMultipleSessions.js
|
|
250
|
+
│ │ 📄 seedUserInfos.js Decode JWT, extract userId + sessionId
|
|
251
|
+
│ │ 📄 interceptRenewToken.js Revoke session on logout (DB-backed)
|
|
252
|
+
│ │ 📄 trackActivity.js Persist lastActiveAt (write-coalesced)
|
|
253
|
+
│ │ 📄 rejectRevokedTokens.js Force-reload signal + cookie clear
|
|
254
|
+
│ │ 📄 preventMultipleSessions.js Cross-pod login lock + active-session check
|
|
210
255
|
│ │
|
|
211
|
-
│ 📁 policies/
|
|
212
|
-
│ │ 📄 has-admin-permission.js Route-level permission check
|
|
213
|
-
│ │
|
|
214
|
-
│ 📁 globals/ ← In-memory state (the "brain")
|
|
215
|
-
│ │ 📄 sessionActivityMap.js Map<"id:email", timestamp>
|
|
216
|
-
│ │ 📄 revokedTokenSet.js Set<email> of revoked sessions
|
|
217
|
-
│ │ 📄 loginLocks.js Set<email> login race-condition guard
|
|
256
|
+
│ 📁 policies/has-admin-permission.js
|
|
218
257
|
│ │
|
|
219
258
|
│ 📁 utils/
|
|
220
259
|
│ │ 📄 errors.js PluginError, ValidationError, AuthorizationError
|
|
221
|
-
│ │ 📄
|
|
260
|
+
│ │ 📄 clearSessionCookies.js Clears koa.sess, koa.sess.sig, refresh + JWT cookies
|
|
222
261
|
│ │
|
|
223
262
|
│ 📁 content-types/
|
|
224
|
-
│ │ 📁 security-settings/
|
|
225
|
-
│ │
|
|
226
|
-
│ │
|
|
227
|
-
│ 📁
|
|
228
|
-
│ │ 📄 index.js Admin-typed routes with policies
|
|
263
|
+
│ │ 📁 security-settings/ Plugin config (singleType)
|
|
264
|
+
│ │ 📁 admin-session/ Per-session activity + revocation
|
|
265
|
+
│ │ 📁 login-lock/ Cross-pod login lock
|
|
266
|
+
│ │ 📁 watcher-lease/ Watcher leader-election lease
|
|
229
267
|
│ │
|
|
230
|
-
│ 📁
|
|
231
|
-
│ 📄 typedefs.js JSDoc type definitions
|
|
268
|
+
│ 📁 routes/index.js Admin-typed routes with policies
|
|
232
269
|
│
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
📄
|
|
236
|
-
|
|
237
|
-
|
|
270
|
+
📁 tests/ ← Vitest test suite (66 tests)
|
|
271
|
+
│ 📁 helpers/
|
|
272
|
+
│ │ 📄 strapi-fake.js sqlite :memory: + Knex harness
|
|
273
|
+
│ │ 📄 mock-strapi.js Mock-based ctx + state helpers
|
|
274
|
+
│ 📁 server/
|
|
275
|
+
│ │ 📄 state.test.js 15 tests — touch, revocation, listIdle, hasActiveSession
|
|
276
|
+
│ │ 📄 state.concurrency.test.js 9 tests — multi-pod login lock + watcher lease
|
|
277
|
+
│ │ 📄 seedUserInfos.test.js 6 tests — JWT decode, ctx hydration
|
|
278
|
+
│ │ 📄 trackActivity.test.js 4 tests — touch, revocation rejection
|
|
279
|
+
│ │ 📄 rejectRevokedTokens.test.js 4 tests — header signal, sessionManager
|
|
280
|
+
│ │ 📄 preventMultipleSessions.test.js 8 tests — login lock flow
|
|
281
|
+
│ │ 📄 interceptRenewToken.test.js 3 tests — logout revocation
|
|
282
|
+
│ │ 📄 autoLogoutChecker.test.js 8 tests — leader election + idle revocation
|
|
283
|
+
│ │ 📄 adminSecurityController.test.js 9 tests — heartbeat + settings validation
|
|
238
284
|
```
|
|
239
285
|
|
|
240
286
|
---
|
|
@@ -267,16 +313,14 @@ All settings live in a **single-type** content-type in the database:
|
|
|
267
313
|
|
|
268
314
|
All routes are **admin-typed** (Strapi handles auth automatically):
|
|
269
315
|
|
|
270
|
-
| Method | Path | Auth | Permission | Description
|
|
271
|
-
| ------ | --------------------------------------- | -------- | ---------------- |
|
|
272
|
-
| `
|
|
273
|
-
| `GET` | `/strapi-security-suite/admin/settings` | 🔒 Admin | `view-configs` | Read settings
|
|
274
|
-
| `POST` | `/strapi-security-suite/admin/settings` | 🔒 Admin | `manage-configs` | Update settings
|
|
316
|
+
| Method | Path | Auth | Permission | Description |
|
|
317
|
+
| ------ | --------------------------------------- | -------- | ---------------- | --------------------------------- |
|
|
318
|
+
| `POST` | `/strapi-security-suite/heartbeat` | 🔒 Admin | — | Activity keep-alive (returns 204) |
|
|
319
|
+
| `GET` | `/strapi-security-suite/admin/settings` | 🔒 Admin | `view-configs` | Read security settings |
|
|
320
|
+
| `POST` | `/strapi-security-suite/admin/settings` | 🔒 Admin | `manage-configs` | Update security settings |
|
|
275
321
|
|
|
276
322
|
### 🔐 Permissions
|
|
277
323
|
|
|
278
|
-
The plugin registers three permission actions:
|
|
279
|
-
|
|
280
324
|
| Permission | What It Allows |
|
|
281
325
|
| ---------------------------------------------- | ------------------------ |
|
|
282
326
|
| `plugin::strapi-security-suite.access` | Access the settings page |
|
|
@@ -285,69 +329,61 @@ The plugin registers three permission actions:
|
|
|
285
329
|
|
|
286
330
|
---
|
|
287
331
|
|
|
288
|
-
##
|
|
332
|
+
## 💡 Recommended Host-App Configuration
|
|
289
333
|
|
|
290
|
-
|
|
334
|
+
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`:
|
|
291
335
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
336
|
+
```javascript
|
|
337
|
+
module.exports = ({ env }) => ({
|
|
338
|
+
auth: {
|
|
339
|
+
secret: env('ADMIN_JWT_SECRET'),
|
|
340
|
+
options: {
|
|
341
|
+
expiresIn: '2m', // ← short access tokens, refreshed transparently by the admin frontend
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
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.
|
|
304
348
|
|
|
305
349
|
---
|
|
306
350
|
|
|
307
351
|
## 🛠️ Development
|
|
308
352
|
|
|
309
353
|
```bash
|
|
310
|
-
# Install dependencies
|
|
311
|
-
yarn
|
|
312
|
-
|
|
313
|
-
#
|
|
314
|
-
yarn
|
|
315
|
-
|
|
316
|
-
#
|
|
317
|
-
yarn
|
|
318
|
-
|
|
319
|
-
#
|
|
320
|
-
yarn
|
|
321
|
-
|
|
322
|
-
# Lint + auto-fix
|
|
323
|
-
yarn lint:fix
|
|
324
|
-
|
|
325
|
-
# Check formatting
|
|
326
|
-
yarn format:check
|
|
327
|
-
|
|
328
|
-
# Auto-format everything
|
|
329
|
-
yarn format
|
|
330
|
-
|
|
331
|
-
# Verify plugin exports
|
|
332
|
-
yarn verify
|
|
354
|
+
yarn install # Install dependencies
|
|
355
|
+
yarn build # Build the plugin
|
|
356
|
+
yarn watch # Auto-rebuild on changes
|
|
357
|
+
yarn lint # ESLint
|
|
358
|
+
yarn lint:fix # ESLint --fix
|
|
359
|
+
yarn format # Prettier
|
|
360
|
+
yarn format:check # Check formatting
|
|
361
|
+
yarn verify # Verify plugin exports
|
|
362
|
+
yarn test # Run the full test suite (66 tests)
|
|
363
|
+
yarn test:watch # Vitest in watch mode
|
|
364
|
+
yarn test:coverage # Coverage report
|
|
333
365
|
```
|
|
334
366
|
|
|
367
|
+
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.
|
|
368
|
+
|
|
335
369
|
---
|
|
336
370
|
|
|
337
371
|
## 🔮 Roadmap
|
|
338
372
|
|
|
339
373
|
| Feature | Status |
|
|
340
374
|
| ----------------------------- | ----------------- |
|
|
341
|
-
| ⏰ Auto-Logout | ✅ Shipped
|
|
342
|
-
| 🚫 Single-Session Enforcement | ✅ Shipped
|
|
343
|
-
| 💀
|
|
344
|
-
| ⚙️ Admin Settings UI | ✅ Shipped
|
|
375
|
+
| ⏰ Auto-Logout | ✅ Shipped (v0.1) |
|
|
376
|
+
| 🚫 Single-Session Enforcement | ✅ Shipped (v0.1) |
|
|
377
|
+
| 💀 Session Revocation | ✅ Shipped (v0.1) |
|
|
378
|
+
| ⚙️ Admin Settings UI | ✅ Shipped (v0.1) |
|
|
379
|
+
| 🌐 **Multi-Pod-Safe State** | ✅ Shipped (v0.4) |
|
|
380
|
+
| 🫀 **Activity Heartbeat** | ✅ Shipped (v0.4) |
|
|
381
|
+
| 🧪 **Test Suite (66 tests)** | ✅ Shipped (v0.4) |
|
|
345
382
|
| 🔑 Password Expiry | 🚧 In Development |
|
|
346
383
|
| 🔄 Non-Reusable Passwords | 🚧 In Development |
|
|
347
384
|
| 📝 Admin Activity Logs | 🔜 Planned |
|
|
348
385
|
| 📊 Security Dashboard | 🔜 Planned |
|
|
349
386
|
| 👊 Brute Force Detection | 🔜 Planned |
|
|
350
|
-
| 👁️ Real-time Session Viewer | 🔜 Planned |
|
|
351
387
|
|
|
352
388
|
---
|
|
353
389
|
|
|
@@ -362,6 +398,9 @@ yarn verify
|
|
|
362
398
|
> "I left my desk for coffee and came back logged out. Respect."
|
|
363
399
|
> — Someone who now understands security
|
|
364
400
|
|
|
401
|
+
> "We scaled to 8 pods on OpenShift and the plugin… just kept working. Sessions, revocations, locks — all consistent."
|
|
402
|
+
> — A platform engineer in v0.4
|
|
403
|
+
|
|
365
404
|
---
|
|
366
405
|
|
|
367
406
|
## 👥 Author
|
|
@@ -380,9 +419,9 @@ yarn verify
|
|
|
380
419
|
|
|
381
420
|
Security should be:
|
|
382
421
|
|
|
383
|
-
- **
|
|
422
|
+
- **Correct under load** — Multi-pod deployments shouldn't degrade the security model into a coin flip.
|
|
423
|
+
- **Cheap to operate** — DB-backed state with write-coalescing. No Redis. No new infra.
|
|
384
424
|
- **Unforgiving** — Idle? Gone. Revoked? Dead. Duplicated? Blocked.
|
|
385
|
-
- **Elegant** — Clean constants, typed errors, layered architecture.
|
|
386
425
|
- **Mildly judgmental** — This plugin _will_ side-eye your stale sessions.
|
|
387
426
|
|
|
388
427
|
> _"The meta-principle: make the right thing the default thing. Discipline compounds. Shortcuts compound too, just in the wrong direction."_
|
|
@@ -5,7 +5,7 @@ const admin = require("@strapi/strapi/admin");
|
|
|
5
5
|
const reactRouterDom = require("react-router-dom");
|
|
6
6
|
const react = require("react");
|
|
7
7
|
const designSystem = require("@strapi/design-system");
|
|
8
|
-
const index = require("./index-
|
|
8
|
+
const index = require("./index-BVsx1rse.js");
|
|
9
9
|
const HomePage = () => {
|
|
10
10
|
const client = admin.useFetchClient();
|
|
11
11
|
const [config, setConfig] = react.useState({
|
|
@@ -3,7 +3,7 @@ import { useFetchClient, Page } from "@strapi/strapi/admin";
|
|
|
3
3
|
import { Routes, Route } from "react-router-dom";
|
|
4
4
|
import { useState, useEffect } from "react";
|
|
5
5
|
import { Box, Typography, Alert, Flex, Divider, NumberInput, Switch, Button } from "@strapi/design-system";
|
|
6
|
-
import { A as API_BASE_PATH, S as SUCCESS_ALERT_DURATION } from "./index-
|
|
6
|
+
import { A as API_BASE_PATH, S as SUCCESS_ALERT_DURATION } from "./index-CrITOuMT.mjs";
|
|
7
7
|
const HomePage = () => {
|
|
8
8
|
const client = useFetchClient();
|
|
9
9
|
const [config, setConfig] = useState({
|
|
@@ -28,11 +28,42 @@ const SERVER_PLUGIN_NAME = "strapi-security-suite";
|
|
|
28
28
|
const API_BASE_PATH = `/${SERVER_PLUGIN_NAME}`;
|
|
29
29
|
const ADMIN_TOKEN_HEADER = "app.admin.tk";
|
|
30
30
|
const SUCCESS_ALERT_DURATION = 3e3;
|
|
31
|
+
const HEARTBEAT_INTERVAL_MS = 3e4;
|
|
31
32
|
const getTrad = (id) => `${PLUGIN_ID}.${id}`;
|
|
33
|
+
const HEARTBEAT_PATH = `${API_BASE_PATH}/heartbeat`;
|
|
34
|
+
const ACTIVITY_EVENTS = ["mousemove", "keydown", "scroll", "click", "touchstart"];
|
|
35
|
+
const installHeartbeat = () => {
|
|
36
|
+
if (typeof window === "undefined" || window.__sssHeartbeatInstalled) return;
|
|
37
|
+
window.__sssHeartbeatInstalled = true;
|
|
38
|
+
let lastFiredAt = 0;
|
|
39
|
+
let inFlight = false;
|
|
40
|
+
const fire = async () => {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
if (inFlight) return;
|
|
43
|
+
if (now - lastFiredAt < HEARTBEAT_INTERVAL_MS) return;
|
|
44
|
+
if (typeof document !== "undefined" && document.hidden) return;
|
|
45
|
+
lastFiredAt = now;
|
|
46
|
+
inFlight = true;
|
|
47
|
+
try {
|
|
48
|
+
await window.fetch(HEARTBEAT_PATH, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
credentials: "include",
|
|
51
|
+
headers: { "Content-Type": "application/json" }
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
} finally {
|
|
55
|
+
inFlight = false;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
for (const evt of ACTIVITY_EVENTS) {
|
|
59
|
+
window.addEventListener(evt, fire, { passive: true });
|
|
60
|
+
}
|
|
61
|
+
};
|
|
32
62
|
const index = {
|
|
33
63
|
/**
|
|
34
|
-
* Registers the plugin with the Strapi admin app
|
|
35
|
-
*
|
|
64
|
+
* Registers the plugin with the Strapi admin app, patches `window.fetch`
|
|
65
|
+
* to intercept forced-logout signal headers, and installs the activity
|
|
66
|
+
* heartbeat listener.
|
|
36
67
|
*
|
|
37
68
|
* @param {Object} app - Strapi admin application instance
|
|
38
69
|
*/
|
|
@@ -56,6 +87,7 @@ const index = {
|
|
|
56
87
|
};
|
|
57
88
|
window.__secureFetchPatched = true;
|
|
58
89
|
}
|
|
90
|
+
installHeartbeat();
|
|
59
91
|
},
|
|
60
92
|
/**
|
|
61
93
|
* Adds the Security Suite settings link under Settings > Global.
|
|
@@ -70,7 +102,7 @@ const index = {
|
|
|
70
102
|
id: getTrad("settings.title"),
|
|
71
103
|
defaultMessage: "Security Suite"
|
|
72
104
|
},
|
|
73
|
-
Component: () => Promise.resolve().then(() => require("./App-
|
|
105
|
+
Component: () => Promise.resolve().then(() => require("./App-B-CRozv4.js")),
|
|
74
106
|
permissions: [
|
|
75
107
|
{
|
|
76
108
|
action: "plugin::strapi-security-suite.access",
|
|
@@ -27,11 +27,42 @@ const SERVER_PLUGIN_NAME = "strapi-security-suite";
|
|
|
27
27
|
const API_BASE_PATH = `/${SERVER_PLUGIN_NAME}`;
|
|
28
28
|
const ADMIN_TOKEN_HEADER = "app.admin.tk";
|
|
29
29
|
const SUCCESS_ALERT_DURATION = 3e3;
|
|
30
|
+
const HEARTBEAT_INTERVAL_MS = 3e4;
|
|
30
31
|
const getTrad = (id) => `${PLUGIN_ID}.${id}`;
|
|
32
|
+
const HEARTBEAT_PATH = `${API_BASE_PATH}/heartbeat`;
|
|
33
|
+
const ACTIVITY_EVENTS = ["mousemove", "keydown", "scroll", "click", "touchstart"];
|
|
34
|
+
const installHeartbeat = () => {
|
|
35
|
+
if (typeof window === "undefined" || window.__sssHeartbeatInstalled) return;
|
|
36
|
+
window.__sssHeartbeatInstalled = true;
|
|
37
|
+
let lastFiredAt = 0;
|
|
38
|
+
let inFlight = false;
|
|
39
|
+
const fire = async () => {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
if (inFlight) return;
|
|
42
|
+
if (now - lastFiredAt < HEARTBEAT_INTERVAL_MS) return;
|
|
43
|
+
if (typeof document !== "undefined" && document.hidden) return;
|
|
44
|
+
lastFiredAt = now;
|
|
45
|
+
inFlight = true;
|
|
46
|
+
try {
|
|
47
|
+
await window.fetch(HEARTBEAT_PATH, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
credentials: "include",
|
|
50
|
+
headers: { "Content-Type": "application/json" }
|
|
51
|
+
});
|
|
52
|
+
} catch {
|
|
53
|
+
} finally {
|
|
54
|
+
inFlight = false;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
for (const evt of ACTIVITY_EVENTS) {
|
|
58
|
+
window.addEventListener(evt, fire, { passive: true });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
31
61
|
const index = {
|
|
32
62
|
/**
|
|
33
|
-
* Registers the plugin with the Strapi admin app
|
|
34
|
-
*
|
|
63
|
+
* Registers the plugin with the Strapi admin app, patches `window.fetch`
|
|
64
|
+
* to intercept forced-logout signal headers, and installs the activity
|
|
65
|
+
* heartbeat listener.
|
|
35
66
|
*
|
|
36
67
|
* @param {Object} app - Strapi admin application instance
|
|
37
68
|
*/
|
|
@@ -55,6 +86,7 @@ const index = {
|
|
|
55
86
|
};
|
|
56
87
|
window.__secureFetchPatched = true;
|
|
57
88
|
}
|
|
89
|
+
installHeartbeat();
|
|
58
90
|
},
|
|
59
91
|
/**
|
|
60
92
|
* Adds the Security Suite settings link under Settings > Global.
|
|
@@ -69,7 +101,7 @@ const index = {
|
|
|
69
101
|
id: getTrad("settings.title"),
|
|
70
102
|
defaultMessage: "Security Suite"
|
|
71
103
|
},
|
|
72
|
-
Component: () => import("./App-
|
|
104
|
+
Component: () => import("./App-CM1kp54o.mjs"),
|
|
73
105
|
permissions: [
|
|
74
106
|
{
|
|
75
107
|
action: "plugin::strapi-security-suite.access",
|
package/dist/admin/index.js
CHANGED
package/dist/admin/index.mjs
CHANGED