domma-cms 0.82.0 → 0.84.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/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/templates/login.html +24 -0
- package/admin/js/views/login.js +7 -7
- package/bin/update.js +7 -3
- package/config/plugins.json +5 -0
- package/package.json +2 -1
- package/plugins/free-tier.lock.json +25 -1
- package/plugins/security/CLAUDE.md +46 -0
- package/plugins/security/admin/lib/blocks.js +46 -0
- package/plugins/security/admin/lib/health.js +148 -0
- package/plugins/security/admin/lib/ip.js +44 -0
- package/plugins/security/admin/lib/lockout.js +78 -0
- package/plugins/security/admin/lib/policy.js +32 -0
- package/plugins/security/admin/lib/probes.js +39 -0
- package/plugins/security/admin/lib/settings.js +86 -0
- package/plugins/security/admin/templates/security.html +170 -0
- package/plugins/security/admin/views/security.js +529 -0
- package/plugins/security/bin/unlock.js +81 -0
- package/plugins/security/config.js +7 -0
- package/plugins/security/plugin.js +303 -0
- package/plugins/security/plugin.json +64 -0
- package/plugins/security/server/common-passwords.NOTICE +3 -0
- package/plugins/security/server/common-passwords.txt +10001 -0
- package/plugins/security/server/facts.js +135 -0
- package/plugins/security/server/state.js +106 -0
- package/plugins/security/tests/security.test.js +134 -0
- package/server/routes/api/auth.js +184 -39
- package/server/routes/api/users.js +20 -0
- package/server/server.js +25 -7
- package/server/services/audit.js +63 -0
- package/server/services/authChecks.js +128 -0
- package/server/services/network.js +49 -0
- package/server/services/plugins.js +5 -1
- package/server/services/sessions.js +171 -0
- package/server/services/siteGitignore.js +1 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security (free) - defends a Domma CMS site and shows where it is weak.
|
|
3
|
+
*
|
|
4
|
+
* - A guard on every request (global: this is a fastify-plugin, as Analytics
|
|
5
|
+
* Pro is): blocked addresses get a bare 403; probe paths (wp-login.php, .env,
|
|
6
|
+
* …) a bare 404 before any rendering, and an address that keeps probing is
|
|
7
|
+
* blocked for a while. Genuinely local calls are never touched.
|
|
8
|
+
* - Sign-in lockout (a 'before' login check - core 0.83's registerLoginCheck),
|
|
9
|
+
* counted from auth:loginFailed; password rules (registerPasswordCheck).
|
|
10
|
+
* - A sign-in log, and notifications for lockouts and a success after many fails.
|
|
11
|
+
* - A health check (server/facts.js gathers, admin/lib/health.js judges).
|
|
12
|
+
*
|
|
13
|
+
* Fails OPEN for page traffic (a bug here logs and lets the request through)
|
|
14
|
+
* and CLOSED for its own admin routes. bin/unlock.js always gets you back in.
|
|
15
|
+
*
|
|
16
|
+
* Routes (/api/plugins/security; the `security` permission: read / manage):
|
|
17
|
+
* GET /health[?refresh=1] · GET /count · GET /overview · GET /signins
|
|
18
|
+
* GET /blocks · POST /blocks · DELETE /blocks (rule | ip) · DELETE /lockouts
|
|
19
|
+
* GET|PUT /settings
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import {fileURLToPath} from 'node:url';
|
|
24
|
+
import fp from 'fastify-plugin';
|
|
25
|
+
import defaultConfig from './config.js';
|
|
26
|
+
import {createState} from './server/state.js';
|
|
27
|
+
import {gatherFacts} from './server/facts.js';
|
|
28
|
+
import {evaluate} from './admin/lib/health.js';
|
|
29
|
+
import {checkSettings, mergeDefaults} from './admin/lib/settings.js';
|
|
30
|
+
import {accountKey, activeLocks, addressKey, lockedUntil, pruneLockouts, recordFailure, recordSuccess, retryText} from './admin/lib/lockout.js';
|
|
31
|
+
import {passwordProblems} from './admin/lib/policy.js';
|
|
32
|
+
import {isProbe} from './admin/lib/probes.js';
|
|
33
|
+
import {blockedBy, isTrusted, pruneBlocks, recordProbe} from './admin/lib/blocks.js';
|
|
34
|
+
import {ipRuleValid, isLoopbackIp, plainIp} from './admin/lib/ip.js';
|
|
35
|
+
import {getConfig, saveConfig} from '../../server/config.js';
|
|
36
|
+
|
|
37
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const NAME = 'security';
|
|
39
|
+
|
|
40
|
+
/** A call from this machine itself, not a relayed visitor. */
|
|
41
|
+
const genuinelyLocal = (request) => isLoopbackIp(request.raw?.socket?.remoteAddress) && !request.headers['x-forwarded-for'];
|
|
42
|
+
|
|
43
|
+
let commonPasswords = null;
|
|
44
|
+
function common() {
|
|
45
|
+
if (commonPasswords) return commonPasswords;
|
|
46
|
+
try {
|
|
47
|
+
commonPasswords = new Set(fs.readFileSync(path.join(__dirname, 'server', 'common-passwords.txt'), 'utf8')
|
|
48
|
+
.split('\n').map(s => s.trim().toLowerCase()).filter(Boolean));
|
|
49
|
+
} catch {
|
|
50
|
+
commonPasswords = new Set();
|
|
51
|
+
}
|
|
52
|
+
return commonPasswords;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function security(fastify, options = {}) {
|
|
56
|
+
let cfg = mergeDefaults({...defaultConfig, ...(options.settings ?? {})});
|
|
57
|
+
const state = createState(path.join(__dirname, 'data'));
|
|
58
|
+
const hooks = options.hooks ?? {};
|
|
59
|
+
const canCheck = typeof hooks.registerLoginCheck === 'function';
|
|
60
|
+
const log = fastify.log;
|
|
61
|
+
const recentProbes = new Map();
|
|
62
|
+
|
|
63
|
+
const notify = (p) => { try { hooks.notify?.(p); } catch { /* notifications are a courtesy */ } };
|
|
64
|
+
hooks.registerNotificationSource?.({id: 'lockouts', label: 'Sign-in lockouts', description: 'An account or address was locked after repeated wrong passwords.', severity: 'warning', audience: 'admins'});
|
|
65
|
+
hooks.registerNotificationSource?.({id: 'suspicious', label: 'Suspicious sign-ins', description: 'Someone signed in after many wrong passwords.', severity: 'warning', audience: 'admins'});
|
|
66
|
+
hooks.registerNotificationSource?.({id: 'probes', label: 'Scanners blocked', description: 'An address probing for weaknesses was blocked.', severity: 'info', audience: 'admins', defaultOn: false});
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------
|
|
69
|
+
// 1. The guard
|
|
70
|
+
// ---------------------------------------------------------------------
|
|
71
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
72
|
+
try {
|
|
73
|
+
if (genuinelyLocal(request)) return;
|
|
74
|
+
const ip = plainIp(request.ip);
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const s = state.get();
|
|
77
|
+
const blocked = blockedBy(s, ip, now);
|
|
78
|
+
if (blocked) {
|
|
79
|
+
reply.code(403).header('content-type', 'text/plain; charset=utf-8').header('cache-control', 'no-store').send('Forbidden');
|
|
80
|
+
return reply;
|
|
81
|
+
}
|
|
82
|
+
if (!cfg.probes.enabled) return;
|
|
83
|
+
const reqPath = String(request.url || '').split('?')[0];
|
|
84
|
+
if (reqPath.startsWith('/api/') || reqPath.startsWith('/admin')) return;
|
|
85
|
+
if (!isProbe(reqPath, {extra: cfg.probes.extra, allow: cfg.probes.allow})) return;
|
|
86
|
+
state.count('probes');
|
|
87
|
+
if (recordProbe(recentProbes, ip, {now, blockAfter: cfg.probes.blockAfter, withinMin: cfg.probes.withinMin}) && !isTrusted(s, ip, now)) {
|
|
88
|
+
recentProbes.delete(ip);
|
|
89
|
+
state.change((st) => { st.temp[ip] = {until: now + cfg.probes.blockForMin * 60_000, reason: `Probing (${reqPath.slice(0, 80)})`, at: new Date(now).toISOString()}; });
|
|
90
|
+
state.count('blocks');
|
|
91
|
+
if (cfg.notify.probeBlocks) {
|
|
92
|
+
notify({source: 'probes', severity: 'info', title: `Scanner blocked: ${ip}`, dedupeKey: `probe:${ip}`,
|
|
93
|
+
body: `${ip} kept asking for pages a Domma site never has (last: ${reqPath.slice(0, 80)}) and is blocked for ${cfg.probes.blockForMin} minutes.`,
|
|
94
|
+
link: '#/plugins/security/blocked'});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
reply.code(404).header('content-type', 'text/plain; charset=utf-8').header('cache-control', 'no-store').send('Not found');
|
|
98
|
+
return reply;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
log.warn(`[security] guard skipped a request: ${err.message}`);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------
|
|
105
|
+
// 2. Sign-in: lockout, password rules, the log
|
|
106
|
+
// ---------------------------------------------------------------------
|
|
107
|
+
if (canCheck) {
|
|
108
|
+
hooks.registerLoginCheck({
|
|
109
|
+
id: 'lockout',
|
|
110
|
+
phase: 'before',
|
|
111
|
+
check({email, ip}) {
|
|
112
|
+
if (!cfg.lockout.enabled || isLoopbackIp(ip)) return {allow: true};
|
|
113
|
+
const s = state.get().lockouts;
|
|
114
|
+
const until = Math.max(lockedUntil(s, accountKey(email)), lockedUntil(s, addressKey(plainIp(ip))));
|
|
115
|
+
if (!until) return {allow: true};
|
|
116
|
+
return {deny: true, status: 429, message: `Too many wrong passwords. Try again ${retryText(until)}.`};
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
hooks.registerPasswordCheck?.({
|
|
120
|
+
id: 'policy',
|
|
121
|
+
check(password, {email, name}) {
|
|
122
|
+
if (!cfg.policy.enabled) return '';
|
|
123
|
+
return passwordProblems(password, {email, name}, {...cfg.policy, common: common()}).join(' ');
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
} else {
|
|
127
|
+
log.warn('[security] this engine has no sign-in checks (needs Domma CMS 0.83+): lockout and password rules are off.');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
hooks.on?.('auth:loginFailed', (e) => {
|
|
131
|
+
try {
|
|
132
|
+
const ip = plainIp(e.ip);
|
|
133
|
+
state.logSignin({at: new Date().toISOString(), ok: false, email: e.email || '', userId: e.userId || null, ip, ua: e.ua || '', reason: e.reason || ''});
|
|
134
|
+
state.count('failed');
|
|
135
|
+
// A refusal by the lockout itself is not another wrong password.
|
|
136
|
+
if (!cfg.lockout.enabled || e.by === 'security:lockout' || isLoopbackIp(ip)) return;
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
const locks = state.change((s) => ({
|
|
139
|
+
account: e.email ? recordFailure(s.lockouts, accountKey(e.email), {now, ...cfg.lockout, threshold: cfg.lockout.accountThreshold}) : 0,
|
|
140
|
+
address: ip ? recordFailure(s.lockouts, addressKey(ip), {now, ...cfg.lockout, threshold: cfg.lockout.addressThreshold}) : 0
|
|
141
|
+
}));
|
|
142
|
+
if (cfg.notify.lockouts && (locks.account || locks.address)) {
|
|
143
|
+
const who = locks.account ? `the account ${e.email}` : `the address ${ip}`;
|
|
144
|
+
notify({source: 'lockouts', severity: 'warning', title: `Sign-in locked: ${locks.account ? e.email : ip}`,
|
|
145
|
+
dedupeKey: `lock:${locks.account ? e.email : ip}`,
|
|
146
|
+
body: `Too many wrong passwords for ${who} (last from ${ip || 'an unknown address'}). Locked until ${new Date(locks.account || locks.address).toLocaleTimeString('en-GB')}.`,
|
|
147
|
+
link: '#/plugins/security/blocked'});
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
log.warn(`[security] failed sign-in not recorded: ${err.message}`);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
hooks.on?.('auth:loginSucceeded', (e) => {
|
|
155
|
+
try {
|
|
156
|
+
const ip = plainIp(e.ip);
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
state.logSignin({at: new Date(now).toISOString(), ok: true, email: e.email || '', userId: e.userId || null, ip, ua: e.ua || ''});
|
|
159
|
+
const recent = state.get().lockouts[accountKey(e.email)]?.fails?.length || 0;
|
|
160
|
+
state.change((s) => {
|
|
161
|
+
recordSuccess(s.lockouts, accountKey(e.email));
|
|
162
|
+
if (ip) s.trusted[ip] = now;
|
|
163
|
+
});
|
|
164
|
+
if (cfg.notify.successAfterFails && recent >= 3) {
|
|
165
|
+
notify({source: 'suspicious', severity: 'warning', title: `Signed in after ${recent} wrong passwords: ${e.email}`,
|
|
166
|
+
dedupeKey: `after-fails:${e.email}`,
|
|
167
|
+
body: `${e.email} signed in from ${ip || 'an unknown address'} after ${recent} wrong passwords in a row. If that was not them, change their password and end their sessions.`,
|
|
168
|
+
link: '#/plugins/security/signins'});
|
|
169
|
+
}
|
|
170
|
+
} catch (err) {
|
|
171
|
+
log.warn(`[security] sign-in not recorded: ${err.message}`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------
|
|
176
|
+
// 3. The API
|
|
177
|
+
// ---------------------------------------------------------------------
|
|
178
|
+
let lastHealth = null;
|
|
179
|
+
async function health(request, refresh = false) {
|
|
180
|
+
if (!refresh && lastHealth && Date.now() - lastHealth.at < 10 * 60_000) return lastHealth;
|
|
181
|
+
const facts = await gatherFacts(request, {settings: cfg, checks: canCheck, twoFactor: Boolean(options.twoFactor)});
|
|
182
|
+
lastHealth = {...evaluate(facts, {staleAdminDays: cfg.staleAdminDays}), at: Date.now()};
|
|
183
|
+
return lastHealth;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
fastify.register(async (routes) => {
|
|
187
|
+
const {authenticate, requirePermission} = options.auth ?? {};
|
|
188
|
+
const may = (action) => ({preHandler: [authenticate, requirePermission('security', action)]});
|
|
189
|
+
|
|
190
|
+
routes.get('/health', may('read'), async (request) => health(request, request.query?.refresh === '1'));
|
|
191
|
+
|
|
192
|
+
// The sidebar badge: checks failing (red when one is critical).
|
|
193
|
+
routes.get('/count', may('read'), async (request) => {
|
|
194
|
+
const h = lastHealth || await health(request).catch(() => null);
|
|
195
|
+
if (!h) return {count: 0};
|
|
196
|
+
const bad = h.checks.filter(c => c.status === 'fail' || c.status === 'warn');
|
|
197
|
+
return {
|
|
198
|
+
count: bad.length,
|
|
199
|
+
label: bad.length ? `${bad.length} security ${bad.length === 1 ? 'check needs' : 'checks need'} attention` : 'All security checks pass',
|
|
200
|
+
...(h.critical && {tone: 'danger'}),
|
|
201
|
+
details: {title: 'Security', rows: [{label: 'Score', value: `${h.score}%`}, {label: 'Needs attention', value: String(bad.length)}],
|
|
202
|
+
items: bad.slice(0, 5).map(c => ({text: c.title, meta: c.status === 'fail' ? 'failing' : 'could be better'})),
|
|
203
|
+
empty: 'Every check passes.'}
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
routes.get('/overview', may('read'), async () => {
|
|
208
|
+
const s = state.get();
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
const day = state.readSignins(now - 86_400_000);
|
|
211
|
+
const today = s.counters.day === new Date().toISOString().slice(0, 10) ? s.counters : {probes: 0, blocks: 0, failed: 0};
|
|
212
|
+
return {
|
|
213
|
+
today,
|
|
214
|
+
failed24h: day.filter(e => !e.ok).length,
|
|
215
|
+
succeeded24h: day.filter(e => e.ok).length,
|
|
216
|
+
locks: activeLocks(s.lockouts, now).length,
|
|
217
|
+
blocked: Object.values(s.temp).filter(t => t.until > now).length + s.rules.length,
|
|
218
|
+
checks: canCheck,
|
|
219
|
+
settings: {lockout: cfg.lockout.enabled, policy: cfg.policy.enabled, probes: cfg.probes.enabled}
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
routes.get('/signins', may('read'), async (request) => {
|
|
224
|
+
const days = Math.min(365, Math.max(1, parseInt(request.query?.days, 10) || cfg.signinDays));
|
|
225
|
+
const list = state.readSignins(Date.now() - days * 86_400_000);
|
|
226
|
+
return {days, entries: list.slice(0, 5000)};
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
routes.get('/blocks', may('read'), async (request) => {
|
|
230
|
+
const s = state.get();
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
return {
|
|
233
|
+
rules: s.rules,
|
|
234
|
+
temp: Object.entries(s.temp).filter(([, t]) => t.until > now).map(([ip, t]) => ({ip, ...t})).sort((a, b) => b.until - a.until),
|
|
235
|
+
locks: activeLocks(s.lockouts, now),
|
|
236
|
+
you: plainIp(request?.ip)
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
routes.post('/blocks', may('manage'), async (request, reply) => {
|
|
241
|
+
const rule = String(request.body?.rule ?? '').trim();
|
|
242
|
+
const note = String(request.body?.note ?? '').trim().slice(0, 200);
|
|
243
|
+
if (!ipRuleValid(rule)) return reply.code(400).send({error: 'Not an address or range (81.2.69.142, 192.168.1.*, 10.0.0.0/8, 2a00:23c7:*).'});
|
|
244
|
+
if (isLoopbackIp(rule)) return reply.code(400).send({error: 'This machine itself cannot be blocked.'});
|
|
245
|
+
const mine = plainIp(request.ip);
|
|
246
|
+
const {ipMatches} = await import('./admin/lib/ip.js');
|
|
247
|
+
if (mine && ipMatches(mine, [rule]) && request.body?.confirmSelf !== true) {
|
|
248
|
+
return reply.code(409).send({error: `That would block you (${mine}). Send it again to confirm.`, self: true});
|
|
249
|
+
}
|
|
250
|
+
state.change((s) => { if (!s.rules.some(r => r.rule === rule)) s.rules.push({rule, note, by: request.user?.email || '', at: new Date().toISOString()}); });
|
|
251
|
+
return {ok: true};
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
routes.delete('/blocks', may('manage'), async (request) => {
|
|
255
|
+
const rule = String(request.body?.rule ?? request.query?.rule ?? '').trim();
|
|
256
|
+
const ip = plainIp(request.body?.ip ?? request.query?.ip ?? '');
|
|
257
|
+
state.change((s) => {
|
|
258
|
+
if (rule) s.rules = s.rules.filter(r => r.rule !== rule);
|
|
259
|
+
if (ip) { delete s.temp[ip]; recentProbes.delete(ip); }
|
|
260
|
+
});
|
|
261
|
+
return {ok: true};
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
routes.delete('/lockouts', may('manage'), async (request) => {
|
|
265
|
+
const key = String(request.body?.key ?? request.query?.key ?? '');
|
|
266
|
+
state.change((s) => {
|
|
267
|
+
if (key === '*') s.lockouts = {};
|
|
268
|
+
else if (key) delete s.lockouts[key];
|
|
269
|
+
});
|
|
270
|
+
return {ok: true};
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
routes.get('/settings', may('read'), async () => ({settings: cfg, checks: canCheck}));
|
|
274
|
+
|
|
275
|
+
routes.put('/settings', may('manage'), async (request, reply) => {
|
|
276
|
+
const {errors, settings} = checkSettings(request.body ?? {}, cfg);
|
|
277
|
+
if (Object.keys(errors).length) {
|
|
278
|
+
return reply.code(400).send({error: Object.values(errors).join(' '), errors});
|
|
279
|
+
}
|
|
280
|
+
const plugins = getConfig('plugins') ?? {};
|
|
281
|
+
plugins[NAME] ??= {};
|
|
282
|
+
plugins[NAME].settings = settings;
|
|
283
|
+
saveConfig('plugins', plugins);
|
|
284
|
+
cfg = settings;
|
|
285
|
+
lastHealth = null;
|
|
286
|
+
return {settings: cfg, checks: canCheck};
|
|
287
|
+
});
|
|
288
|
+
}, {prefix: `/api/plugins/${NAME}`});
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------
|
|
291
|
+
// 4. Daily: forget what no longer matters
|
|
292
|
+
// ---------------------------------------------------------------------
|
|
293
|
+
const tidy = () => {
|
|
294
|
+
state.change((s) => { pruneLockouts(s.lockouts, {windowMin: cfg.lockout.windowMin}); pruneBlocks(s); });
|
|
295
|
+
state.pruneSignins(cfg.signinDays);
|
|
296
|
+
};
|
|
297
|
+
const daily = setInterval(tidy, 6 * 3_600_000);
|
|
298
|
+
daily.unref?.();
|
|
299
|
+
setTimeout(tidy, 60_000).unref?.();
|
|
300
|
+
fastify.addHook('onClose', (_i, done) => { clearInterval(daily); state.close(); done(); });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export default fp(security, {name: 'security'});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "security",
|
|
3
|
+
"displayName": "Security",
|
|
4
|
+
"version": "1.0.1",
|
|
5
|
+
"tier": "free",
|
|
6
|
+
"description": "Keeps a Domma CMS site safe: a health check of what is weak and how to fix it, sign-in lockout after repeated wrong passwords, password rules, a log of every sign-in, and blocking of the scanners that probe every site for WordPress, .env files and the like.",
|
|
7
|
+
"author": "Domma CMS",
|
|
8
|
+
"date": "2026-09-26",
|
|
9
|
+
"icon": "shield",
|
|
10
|
+
"permissions": [
|
|
11
|
+
{
|
|
12
|
+
"key": "security",
|
|
13
|
+
"label": "Security",
|
|
14
|
+
"group": "Plugins",
|
|
15
|
+
"description": "See the site's security (read) or change its settings, blocks and lockouts (manage).",
|
|
16
|
+
"actions": [
|
|
17
|
+
{
|
|
18
|
+
"key": "read",
|
|
19
|
+
"label": "View",
|
|
20
|
+
"description": "See the health check, sign-ins and blocks"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"key": "manage",
|
|
24
|
+
"label": "Manage",
|
|
25
|
+
"description": "Change settings; block, unblock and unlock"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"grant": [
|
|
29
|
+
"admin"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"admin": {
|
|
34
|
+
"sidebar": [
|
|
35
|
+
{
|
|
36
|
+
"id": "security",
|
|
37
|
+
"text": "Security",
|
|
38
|
+
"icon": "shield",
|
|
39
|
+
"url": "#/plugins/security",
|
|
40
|
+
"section": "#/plugins/security",
|
|
41
|
+
"permission": "security",
|
|
42
|
+
"countUrl": "/api/plugins/security/count"
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"routes": [
|
|
46
|
+
{
|
|
47
|
+
"path": "/plugins/security",
|
|
48
|
+
"view": "security-main",
|
|
49
|
+
"title": "Security"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"path": "/plugins/security/:tab",
|
|
53
|
+
"view": "security-main",
|
|
54
|
+
"title": "Security"
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"views": {
|
|
58
|
+
"security-main": {
|
|
59
|
+
"entry": "security/admin/views/security.js",
|
|
60
|
+
"exportName": "securityView"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|