badmfck-api-server 5.0.7 → 5.0.9
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/dist/apiServer/APIService.d.ts +3 -0
- package/dist/apiServer/APIService.js +3 -3
- package/dist/apiServer/BaseEndpoint.js +2 -2
- package/dist/apiServer/LogService.d.ts +3 -0
- package/dist/apiServer/LogService.js +15 -4
- package/dist/apiServer/MysqlService.js +19 -19
- package/dist/apiServer/TimeframeService.js +3 -3
- package/dist/apiServer/db/MysqlAdapter.js +1 -1
- package/dist/apiServer/deployment/DeployerService.js +4 -4
- package/dist/apiServer/deployment/Notifier.js +3 -2
- package/dist/apiServer/external/ExternalService.js +3 -2
- package/dist/apiServer/external/MicroserviceClient.js +3 -2
- package/dist/apiServer/monitor/Monitor.d.ts +5 -0
- package/dist/apiServer/monitor/Monitor.js +114 -30
- package/dist/apiServer/monitor/MonitorAuth.d.ts +69 -0
- package/dist/apiServer/monitor/MonitorAuth.js +310 -0
- package/dist/apiServer/monitor/index.html +80 -68
- package/monitoring.help.md +266 -0
- package/package.json +1 -1
|
@@ -9,7 +9,6 @@ const APIService_1 = require("../APIService");
|
|
|
9
9
|
const BaseEndpoint_1 = require("../BaseEndpoint");
|
|
10
10
|
const BaseService_1 = require("../BaseService");
|
|
11
11
|
const LogService_1 = require("../LogService");
|
|
12
|
-
const crypto_1 = __importDefault(require("crypto"));
|
|
13
12
|
const DefaultErrors_1 = __importDefault(require("../structures/DefaultErrors"));
|
|
14
13
|
const fs_1 = __importDefault(require("fs"));
|
|
15
14
|
const path_1 = __importDefault(require("path"));
|
|
@@ -19,6 +18,7 @@ const DBService_1 = require("../DBService");
|
|
|
19
18
|
const SystemInfo_1 = require("./SystemInfo");
|
|
20
19
|
const ProcessMonitor_1 = require("./ProcessMonitor");
|
|
21
20
|
const LogFiles_1 = require("./LogFiles");
|
|
21
|
+
const MonitorAuth_1 = require("./MonitorAuth");
|
|
22
22
|
exports.S_MONITOR_REGISTRATE_ACTION = new badmfck_signal_1.Signal();
|
|
23
23
|
exports.S_MONITOR_RESTART_REQUESTED = new badmfck_signal_1.Signal();
|
|
24
24
|
const logMap = {
|
|
@@ -40,6 +40,7 @@ class Monitor extends BaseEndpoint_1.BaseEndpoint {
|
|
|
40
40
|
constructor(options) {
|
|
41
41
|
super("sm");
|
|
42
42
|
this.apiServiceOptions = options;
|
|
43
|
+
MonitorAuth_1.MonitorAuth.configure(options.monitorAuthFile, options.projectName);
|
|
43
44
|
this.registerEndpoints([
|
|
44
45
|
{ ignoreInterceptor: true, endpoint: "html", handler: this.html },
|
|
45
46
|
{ ignoreInterceptor: true, endpoint: "log", handler: this.logs },
|
|
@@ -48,8 +49,22 @@ class Monitor extends BaseEndpoint_1.BaseEndpoint {
|
|
|
48
49
|
{ ignoreInterceptor: true, endpoint: "sysinfo", handler: this.sysinfo },
|
|
49
50
|
{ ignoreInterceptor: true, endpoint: "procinfo", handler: this.procinfo },
|
|
50
51
|
{ ignoreInterceptor: true, endpoint: "fdreport", handler: this.fdreport },
|
|
52
|
+
{ ignoreInterceptor: true, endpoint: "login", handler: this.login, validationModel: {
|
|
53
|
+
login: "",
|
|
54
|
+
password: "",
|
|
55
|
+
totp: "",
|
|
56
|
+
$__totp_optional: true
|
|
57
|
+
} },
|
|
58
|
+
{ ignoreInterceptor: true, endpoint: "enroll2fa", handler: this.enroll2fa, validationModel: {
|
|
59
|
+
enrollmentToken: "",
|
|
60
|
+
totp: ""
|
|
61
|
+
} },
|
|
62
|
+
{ ignoreInterceptor: true, endpoint: "logout", handler: this.logout },
|
|
51
63
|
{ ignoreInterceptor: true, endpoint: "restart", handler: this.restart, validationModel: {
|
|
52
|
-
confirm: ""
|
|
64
|
+
confirm: "",
|
|
65
|
+
password: "",
|
|
66
|
+
totp: "",
|
|
67
|
+
$__totp_optional: true
|
|
53
68
|
} },
|
|
54
69
|
{ ignoreInterceptor: true, endpoint: "inventory", handler: this.inventory },
|
|
55
70
|
{ ignoreInterceptor: true, endpoint: "httpstats", handler: this.httpstats },
|
|
@@ -120,11 +135,11 @@ class Monitor extends BaseEndpoint_1.BaseEndpoint {
|
|
|
120
135
|
};
|
|
121
136
|
}
|
|
122
137
|
async controllers(req) {
|
|
123
|
-
await this.
|
|
138
|
+
await this.requireAccess(req, "dashboard");
|
|
124
139
|
return {};
|
|
125
140
|
}
|
|
126
141
|
async changeLogLevel(req) {
|
|
127
|
-
await this.
|
|
142
|
+
await this.requireAccess(req, "admin");
|
|
128
143
|
const val = logMap[req.data.level];
|
|
129
144
|
if (!val)
|
|
130
145
|
throw { ...DefaultErrors_1.default.BAD_REQUEST, details: "bad log level" };
|
|
@@ -132,7 +147,7 @@ class Monitor extends BaseEndpoint_1.BaseEndpoint {
|
|
|
132
147
|
return { data: (await LogService_1.REQ_CURRENT_LOG_LEVEL.request()) };
|
|
133
148
|
}
|
|
134
149
|
async logs(req) {
|
|
135
|
-
await this.
|
|
150
|
+
await this.requireAccess(req, "logs");
|
|
136
151
|
const services = await LogService_1.REQ_LOG_UNIQUE_SERVICES.request();
|
|
137
152
|
const log = await LogService_1.REQ_LOG.request({
|
|
138
153
|
lastID: req.data.lastID ?? 0
|
|
@@ -145,28 +160,89 @@ class Monitor extends BaseEndpoint_1.BaseEndpoint {
|
|
|
145
160
|
};
|
|
146
161
|
}
|
|
147
162
|
async netlog(req) {
|
|
148
|
-
await this.
|
|
163
|
+
await this.requireAccess(req, "logs");
|
|
149
164
|
const log = await APIService_1.REQ_HTTP_LOG.request(req.data);
|
|
150
165
|
return { data: log };
|
|
151
166
|
}
|
|
152
167
|
async outgoingNetlog(req) {
|
|
153
|
-
await this.
|
|
168
|
+
await this.requireAccess(req, "logs");
|
|
154
169
|
return { data: Http_1.Http.getOutgoingLogSnapshot() };
|
|
155
170
|
}
|
|
156
171
|
async sysinfo(req) {
|
|
157
|
-
await this.
|
|
172
|
+
await this.requireAccess(req, "dashboard");
|
|
158
173
|
return { data: await (0, SystemInfo_1.collectSystemInfo)() };
|
|
159
174
|
}
|
|
160
175
|
async procinfo(req) {
|
|
161
|
-
await this.
|
|
176
|
+
await this.requireAccess(req, "dashboard");
|
|
162
177
|
return { data: await ProcessMonitor_1.ProcessMonitor.getSnapshot() };
|
|
163
178
|
}
|
|
164
179
|
async fdreport(req) {
|
|
165
|
-
await this.
|
|
180
|
+
await this.requireAccess(req, "dashboard");
|
|
166
181
|
return { data: await ProcessMonitor_1.ProcessMonitor.getFdReport() };
|
|
167
182
|
}
|
|
183
|
+
async login(req) {
|
|
184
|
+
const users = await APIService_1.REQ_MONITOR_USERS.request();
|
|
185
|
+
if (!users || users.length === 0)
|
|
186
|
+
throw { ...DefaultErrors_1.default.UNAUTHORIZED, details: "No users found for monitor access, please set up users first." };
|
|
187
|
+
const { user, error, enrollmentRequired } = MonitorAuth_1.MonitorAuth.authenticate(users, req.data.login, req.data.password, req.data.totp);
|
|
188
|
+
if (!user || error) {
|
|
189
|
+
(0, LogService_1.logWarn)(`Monitor login failed for "${req.data.login}": ${error}`);
|
|
190
|
+
throw { ...DefaultErrors_1.default.UNAUTHORIZED, details: error ?? "Invalid user" };
|
|
191
|
+
}
|
|
192
|
+
if (enrollmentRequired) {
|
|
193
|
+
const issuer = this.apiServiceOptions.projectName || "Monitor";
|
|
194
|
+
const { token, secret, uri } = MonitorAuth_1.MonitorAuth.beginEnrollment(user.login, issuer);
|
|
195
|
+
(0, LogService_1.logInfo)(`Monitor 2FA enrollment started for "${user.login}"`, "Monitor");
|
|
196
|
+
return { data: {
|
|
197
|
+
enrollmentRequired: true,
|
|
198
|
+
enrollmentToken: token,
|
|
199
|
+
secret,
|
|
200
|
+
otpauthUri: uri,
|
|
201
|
+
expiresInMs: MonitorAuth_1.MonitorAuth.ENROLLMENT_TTL_MS,
|
|
202
|
+
} };
|
|
203
|
+
}
|
|
204
|
+
const session = MonitorAuth_1.MonitorAuth.createSession(user.login);
|
|
205
|
+
(0, LogService_1.logInfo)(`Monitor login: "${user.login}"`, "Monitor");
|
|
206
|
+
return { data: {
|
|
207
|
+
token: session.token,
|
|
208
|
+
login: user.login,
|
|
209
|
+
idleTimeoutMs: MonitorAuth_1.MonitorAuth.SESSION_IDLE_MS,
|
|
210
|
+
maxLifetimeMs: MonitorAuth_1.MonitorAuth.SESSION_MAX_MS,
|
|
211
|
+
twoFactor: true,
|
|
212
|
+
...MonitorAuth_1.MonitorAuth.describeRole(user.role),
|
|
213
|
+
} };
|
|
214
|
+
}
|
|
215
|
+
async enroll2fa(req) {
|
|
216
|
+
const { login, error } = MonitorAuth_1.MonitorAuth.completeEnrollment(req.data.enrollmentToken, req.data.totp);
|
|
217
|
+
if (!login || error) {
|
|
218
|
+
(0, LogService_1.logWarn)(`Monitor 2FA enrollment failed: ${error}`);
|
|
219
|
+
throw { ...DefaultErrors_1.default.UNAUTHORIZED, details: error ?? "Enrollment failed" };
|
|
220
|
+
}
|
|
221
|
+
const users = await APIService_1.REQ_MONITOR_USERS.request();
|
|
222
|
+
const user = users?.find(u => u?.login === login);
|
|
223
|
+
const session = MonitorAuth_1.MonitorAuth.createSession(login);
|
|
224
|
+
return { data: {
|
|
225
|
+
token: session.token,
|
|
226
|
+
login,
|
|
227
|
+
idleTimeoutMs: MonitorAuth_1.MonitorAuth.SESSION_IDLE_MS,
|
|
228
|
+
maxLifetimeMs: MonitorAuth_1.MonitorAuth.SESSION_MAX_MS,
|
|
229
|
+
twoFactor: true,
|
|
230
|
+
...MonitorAuth_1.MonitorAuth.describeRole(user?.role),
|
|
231
|
+
} };
|
|
232
|
+
}
|
|
233
|
+
async logout(req) {
|
|
234
|
+
const token = (req.headers['authorization'] ?? "").split(" ").pop() ?? "";
|
|
235
|
+
MonitorAuth_1.MonitorAuth.revokeSession(token);
|
|
236
|
+
return { data: { ok: true } };
|
|
237
|
+
}
|
|
168
238
|
async restart(req) {
|
|
169
|
-
const login = await this.
|
|
239
|
+
const login = await this.requireAccess(req, "admin");
|
|
240
|
+
const users = await APIService_1.REQ_MONITOR_USERS.request();
|
|
241
|
+
const stepUp = MonitorAuth_1.MonitorAuth.authenticate(users ?? [], login, req.data.password, req.data.totp);
|
|
242
|
+
if (!stepUp.user) {
|
|
243
|
+
(0, LogService_1.logWarn)(`Monitor restart re-auth failed for "${login}": ${stepUp.error}`);
|
|
244
|
+
throw { ...DefaultErrors_1.default.UNAUTHORIZED, details: "Re-authentication required: " + (stepUp.error ?? "invalid credentials") };
|
|
245
|
+
}
|
|
170
246
|
const id = ProcessMonitor_1.ProcessMonitor.getPm2Identity();
|
|
171
247
|
if (!id.managed)
|
|
172
248
|
throw { ...DefaultErrors_1.default.BAD_REQUEST, details: "Process is not managed by pm2 (no pm_id in env) — refusing to exit, nothing would restart it." };
|
|
@@ -183,11 +259,11 @@ class Monitor extends BaseEndpoint_1.BaseEndpoint {
|
|
|
183
259
|
return { data: { restarting: true, name: id.name, pmId: id.pmId, by: login, graceMs } };
|
|
184
260
|
}
|
|
185
261
|
async httpstats(req) {
|
|
186
|
-
await this.
|
|
262
|
+
await this.requireAccess(req, "dashboard");
|
|
187
263
|
return { data: Http_1.Http.getHttpStats() };
|
|
188
264
|
}
|
|
189
265
|
async inventory(req) {
|
|
190
|
-
await this.
|
|
266
|
+
await this.requireAccess(req, "dashboard");
|
|
191
267
|
const now = Date.now();
|
|
192
268
|
const services = BaseService_1.BaseService.allInstances.map(s => ({
|
|
193
269
|
className: s.constructor.name,
|
|
@@ -213,16 +289,16 @@ class Monitor extends BaseEndpoint_1.BaseEndpoint {
|
|
|
213
289
|
};
|
|
214
290
|
}
|
|
215
291
|
async metrics(req) {
|
|
216
|
-
await this.
|
|
292
|
+
await this.requireAccess(req, "dashboard");
|
|
217
293
|
const result = await MonitorService_1.REQ_STAT.request(req.data);
|
|
218
294
|
return { data: result };
|
|
219
295
|
}
|
|
220
296
|
async logfiles(req) {
|
|
221
|
-
await this.
|
|
297
|
+
await this.requireAccess(req, "logs");
|
|
222
298
|
return { data: await (0, LogFiles_1.listLogSources)(this.apiServiceOptions.logFiles) };
|
|
223
299
|
}
|
|
224
300
|
async logtail(req) {
|
|
225
|
-
await this.
|
|
301
|
+
await this.requireAccess(req, "logs");
|
|
226
302
|
const num = (v) => (v === undefined || v === null || v === "" ? undefined : Number(v));
|
|
227
303
|
try {
|
|
228
304
|
return { data: await (0, LogFiles_1.tailLog)(req.data.source, this.apiServiceOptions.logFiles, {
|
|
@@ -239,38 +315,46 @@ class Monitor extends BaseEndpoint_1.BaseEndpoint {
|
|
|
239
315
|
}
|
|
240
316
|
}
|
|
241
317
|
async metricsWeeks(req) {
|
|
242
|
-
await this.
|
|
318
|
+
await this.requireAccess(req, "dashboard");
|
|
243
319
|
return { data: await MonitorService_1.REQ_STAT_WEEKS.request() };
|
|
244
320
|
}
|
|
245
321
|
async metricsWeek(req) {
|
|
246
|
-
await this.
|
|
322
|
+
await this.requireAccess(req, "dashboard");
|
|
247
323
|
return { data: await MonitorService_1.REQ_STAT_WEEK.request({ weekKey: req.data.weekKey }) };
|
|
248
324
|
}
|
|
249
325
|
async connections(req) {
|
|
250
|
-
await this.
|
|
326
|
+
await this.requireAccess(req, "dashboard");
|
|
251
327
|
return { data: Http_1.Http.getActiveConnectionsSnapshot() };
|
|
252
328
|
}
|
|
253
329
|
async db(req) {
|
|
254
|
-
await this.
|
|
330
|
+
await this.requireAccess(req, "dashboard");
|
|
255
331
|
return { data: DBService_1.DBService.healthcheck(req.data.range) };
|
|
256
332
|
}
|
|
333
|
+
async requireAccess(req, capability) {
|
|
334
|
+
const login = await this.checkAuthentication(req);
|
|
335
|
+
const users = await APIService_1.REQ_MONITOR_USERS.request();
|
|
336
|
+
const user = users?.find(u => u?.login === login);
|
|
337
|
+
if (!MonitorAuth_1.MonitorAuth.roleAllows(user?.role, capability)) {
|
|
338
|
+
const { role } = MonitorAuth_1.MonitorAuth.describeRole(user?.role);
|
|
339
|
+
(0, LogService_1.logWarn)(`Monitor access denied: "${login}" (role ${role}) tried a "${capability}" endpoint`);
|
|
340
|
+
throw { ...DefaultErrors_1.default.FORBIDDEN, details: `Role "${role}" is not allowed to access ${capability} endpoints` };
|
|
341
|
+
}
|
|
342
|
+
return login;
|
|
343
|
+
}
|
|
257
344
|
async checkAuthentication(req) {
|
|
258
345
|
const users = await APIService_1.REQ_MONITOR_USERS.request();
|
|
259
346
|
if (!users || users.length == 0)
|
|
260
347
|
throw { ...DefaultErrors_1.default.UNAUTHORIZED, details: "No users found for monitor access, please set up users first." };
|
|
261
|
-
|
|
348
|
+
const header = req.headers['authorization'];
|
|
262
349
|
if (!header)
|
|
263
350
|
throw { ...DefaultErrors_1.default.UNAUTHORIZED, details: "No authorization header found" };
|
|
264
|
-
|
|
265
|
-
if (!
|
|
351
|
+
const token = header.split(" ").pop() ?? "";
|
|
352
|
+
if (!token || token.length < 32)
|
|
266
353
|
throw { ...DefaultErrors_1.default.UNAUTHORIZED, details: "Wrong authorization header" };
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
throw { ...DefaultErrors_1.default.UNAUTHORIZED, details: "Invalid user" };
|
|
354
|
+
const session = MonitorAuth_1.MonitorAuth.touchSession(token);
|
|
355
|
+
if (!session)
|
|
356
|
+
throw { ...DefaultErrors_1.default.UNAUTHORIZED, details: "Session is invalid or expired — authenticate via sm/login" };
|
|
357
|
+
return session.login;
|
|
274
358
|
}
|
|
275
359
|
}
|
|
276
360
|
exports.Monitor = Monitor;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { IMonitorUser } from "../APIService";
|
|
2
|
+
export interface IMonitorSession {
|
|
3
|
+
token: string;
|
|
4
|
+
login: string;
|
|
5
|
+
createdAt: number;
|
|
6
|
+
lastSeenAt: number;
|
|
7
|
+
}
|
|
8
|
+
export type TMonitorRole = "admin" | "it" | "analytic";
|
|
9
|
+
export type TMonitorCapability = "dashboard" | "logs" | "admin";
|
|
10
|
+
export declare class MonitorAuth {
|
|
11
|
+
private static readonly ROLE_CAPS;
|
|
12
|
+
static roleAllows(role: string | undefined | null, capability: TMonitorCapability): boolean;
|
|
13
|
+
static describeRole(role: string | undefined | null): {
|
|
14
|
+
role: string;
|
|
15
|
+
capabilities: TMonitorCapability[];
|
|
16
|
+
};
|
|
17
|
+
private static authFile;
|
|
18
|
+
private static projectKey;
|
|
19
|
+
private static enrolled;
|
|
20
|
+
private static storeLoaded;
|
|
21
|
+
static configure(authFile?: string | null, projectName?: string): void;
|
|
22
|
+
private static loadStore;
|
|
23
|
+
private static saveStore;
|
|
24
|
+
static secretFor(user: IMonitorUser): string | null;
|
|
25
|
+
static isEnrolled(user: IMonitorUser): boolean;
|
|
26
|
+
static MIN_PASSWORD_LENGTH: number;
|
|
27
|
+
static SESSION_IDLE_MS: number;
|
|
28
|
+
static SESSION_MAX_MS: number;
|
|
29
|
+
static MAX_FAILED_ATTEMPTS: number;
|
|
30
|
+
static LOCKOUT_MS: number;
|
|
31
|
+
private static sessions;
|
|
32
|
+
private static failures;
|
|
33
|
+
private static usedTotpCounter;
|
|
34
|
+
static passwordPolicyError(password: string | undefined | null): string | null;
|
|
35
|
+
static compliantUsers(users: IMonitorUser[]): IMonitorUser[];
|
|
36
|
+
static policyReportFor(users: IMonitorUser[]): string[];
|
|
37
|
+
private static base32Decode;
|
|
38
|
+
private static hotp;
|
|
39
|
+
static verifyTotp(login: string, secretBase32: string, code: string): string | null;
|
|
40
|
+
static generateTotpSecret(login: string, issuer?: string): {
|
|
41
|
+
secret: string;
|
|
42
|
+
uri: string;
|
|
43
|
+
};
|
|
44
|
+
static lockoutRemainingMs(login: string): number;
|
|
45
|
+
private static registerFailure;
|
|
46
|
+
private static clearFailures;
|
|
47
|
+
private static sameSecret;
|
|
48
|
+
static authenticate(users: IMonitorUser[], login: string, password: string, totp?: string): {
|
|
49
|
+
user?: IMonitorUser;
|
|
50
|
+
error?: string;
|
|
51
|
+
enrollmentRequired?: boolean;
|
|
52
|
+
};
|
|
53
|
+
private static pendingEnrollments;
|
|
54
|
+
static ENROLLMENT_TTL_MS: number;
|
|
55
|
+
static beginEnrollment(login: string, issuer?: string): {
|
|
56
|
+
token: string;
|
|
57
|
+
secret: string;
|
|
58
|
+
uri: string;
|
|
59
|
+
};
|
|
60
|
+
static completeEnrollment(token: string, code: string): {
|
|
61
|
+
login?: string;
|
|
62
|
+
error?: string;
|
|
63
|
+
};
|
|
64
|
+
private static purgeSessions;
|
|
65
|
+
static createSession(login: string): IMonitorSession;
|
|
66
|
+
static touchSession(token: string): IMonitorSession | null;
|
|
67
|
+
static revokeSession(token: string): void;
|
|
68
|
+
static revokeAllFor(login: string): void;
|
|
69
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.MonitorAuth = void 0;
|
|
7
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const LogService_1 = require("../LogService");
|
|
11
|
+
class MonitorAuth {
|
|
12
|
+
static ROLE_CAPS = {
|
|
13
|
+
admin: ["dashboard", "logs", "admin"],
|
|
14
|
+
it: ["dashboard", "logs"],
|
|
15
|
+
analytic: ["dashboard"],
|
|
16
|
+
};
|
|
17
|
+
static roleAllows(role, capability) {
|
|
18
|
+
const caps = MonitorAuth.ROLE_CAPS[role] ?? MonitorAuth.ROLE_CAPS.admin;
|
|
19
|
+
return caps.includes(capability);
|
|
20
|
+
}
|
|
21
|
+
static describeRole(role) {
|
|
22
|
+
const r = role;
|
|
23
|
+
return { role: MonitorAuth.ROLE_CAPS[r] ? r : "admin",
|
|
24
|
+
capabilities: MonitorAuth.ROLE_CAPS[r] ?? MonitorAuth.ROLE_CAPS.admin };
|
|
25
|
+
}
|
|
26
|
+
static authFile = null;
|
|
27
|
+
static projectKey = "default";
|
|
28
|
+
static enrolled = new Map();
|
|
29
|
+
static storeLoaded = false;
|
|
30
|
+
static configure(authFile, projectName) {
|
|
31
|
+
MonitorAuth.authFile = authFile && authFile.length > 0 ? authFile : null;
|
|
32
|
+
MonitorAuth.projectKey = (projectName && projectName.length > 0 ? projectName : "default");
|
|
33
|
+
MonitorAuth.enrolled.clear();
|
|
34
|
+
MonitorAuth.storeLoaded = false;
|
|
35
|
+
MonitorAuth.loadStore();
|
|
36
|
+
if (!MonitorAuth.authFile)
|
|
37
|
+
(0, LogService_1.logWarn)("Monitor 2FA: no monitorAuthFile configured — enrollments live in memory only and are LOST on restart");
|
|
38
|
+
}
|
|
39
|
+
static loadStore() {
|
|
40
|
+
if (MonitorAuth.storeLoaded)
|
|
41
|
+
return;
|
|
42
|
+
MonitorAuth.storeLoaded = true;
|
|
43
|
+
if (!MonitorAuth.authFile)
|
|
44
|
+
return;
|
|
45
|
+
try {
|
|
46
|
+
if (!fs_1.default.existsSync(MonitorAuth.authFile))
|
|
47
|
+
return;
|
|
48
|
+
const raw = JSON.parse(fs_1.default.readFileSync(MonitorAuth.authFile, "utf-8"));
|
|
49
|
+
const section = raw?.projects?.[MonitorAuth.projectKey]?.users ?? raw?.users ?? {};
|
|
50
|
+
for (const [login, rec] of Object.entries(section)) {
|
|
51
|
+
const r = rec;
|
|
52
|
+
if (r && typeof r.secret === "string" && r.secret.length > 0)
|
|
53
|
+
MonitorAuth.enrolled.set(login, {
|
|
54
|
+
secret: r.secret,
|
|
55
|
+
enrolledAt: Number(r.enrolledAt) || 0,
|
|
56
|
+
lastCounter: Number(r.lastCounter) || 0,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
(0, LogService_1.logInfo)(`Monitor 2FA: loaded ${MonitorAuth.enrolled.size} enrollment(s) for project "${MonitorAuth.projectKey}"`, "MonitorAuth");
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
(0, LogService_1.logWarn)("Monitor 2FA: cannot read store, treating as empty: " + (e?.message ?? e));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
static saveStore() {
|
|
66
|
+
if (!MonitorAuth.authFile)
|
|
67
|
+
return;
|
|
68
|
+
try {
|
|
69
|
+
const users = {};
|
|
70
|
+
for (const [login, rec] of MonitorAuth.enrolled)
|
|
71
|
+
users[login] = rec;
|
|
72
|
+
let doc = { version: 1, projects: {} };
|
|
73
|
+
try {
|
|
74
|
+
if (fs_1.default.existsSync(MonitorAuth.authFile)) {
|
|
75
|
+
const existing = JSON.parse(fs_1.default.readFileSync(MonitorAuth.authFile, "utf-8"));
|
|
76
|
+
if (existing && typeof existing === "object")
|
|
77
|
+
doc = { version: 1, projects: existing.projects ?? {} };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
doc.projects[MonitorAuth.projectKey] = { users };
|
|
82
|
+
const tmp = MonitorAuth.authFile + ".tmp";
|
|
83
|
+
fs_1.default.mkdirSync(path_1.default.dirname(MonitorAuth.authFile), { recursive: true });
|
|
84
|
+
fs_1.default.writeFileSync(tmp, JSON.stringify(doc, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
85
|
+
fs_1.default.renameSync(tmp, MonitorAuth.authFile);
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
(0, LogService_1.logWarn)("Monitor 2FA: cannot persist store: " + (e?.message ?? e));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
static secretFor(user) {
|
|
92
|
+
MonitorAuth.loadStore();
|
|
93
|
+
if (user?.totpSecret)
|
|
94
|
+
return user.totpSecret;
|
|
95
|
+
return MonitorAuth.enrolled.get(user?.login)?.secret ?? null;
|
|
96
|
+
}
|
|
97
|
+
static isEnrolled(user) {
|
|
98
|
+
return !!MonitorAuth.secretFor(user);
|
|
99
|
+
}
|
|
100
|
+
static MIN_PASSWORD_LENGTH = 12;
|
|
101
|
+
static SESSION_IDLE_MS = 15 * 60 * 1000;
|
|
102
|
+
static SESSION_MAX_MS = 8 * 60 * 60 * 1000;
|
|
103
|
+
static MAX_FAILED_ATTEMPTS = 10;
|
|
104
|
+
static LOCKOUT_MS = 30 * 60 * 1000;
|
|
105
|
+
static sessions = new Map();
|
|
106
|
+
static failures = new Map();
|
|
107
|
+
static usedTotpCounter = new Map();
|
|
108
|
+
static passwordPolicyError(password) {
|
|
109
|
+
if (typeof password !== "string" || password.length === 0)
|
|
110
|
+
return "password is empty";
|
|
111
|
+
if (password.trim().length === 0)
|
|
112
|
+
return "password is whitespace only";
|
|
113
|
+
if (password.length < MonitorAuth.MIN_PASSWORD_LENGTH)
|
|
114
|
+
return `password must be at least ${MonitorAuth.MIN_PASSWORD_LENGTH} characters`;
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
static compliantUsers(users) {
|
|
118
|
+
return users.filter(u => u && typeof u.login === "string" && u.login.length > 0
|
|
119
|
+
&& MonitorAuth.passwordPolicyError(u.password) === null);
|
|
120
|
+
}
|
|
121
|
+
static policyReportFor(users) {
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const u of users || []) {
|
|
124
|
+
const reason = !u?.login ? "login is empty" : MonitorAuth.passwordPolicyError(u.password);
|
|
125
|
+
if (reason)
|
|
126
|
+
out.push(`${u?.login || "<no login>"}: ${reason}`);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
static base32Decode(input) {
|
|
131
|
+
const A = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
132
|
+
let bits = 0, value = 0;
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const ch of input.replace(/=+$/, "").replace(/\s+/g, "").toUpperCase()) {
|
|
135
|
+
const idx = A.indexOf(ch);
|
|
136
|
+
if (idx < 0)
|
|
137
|
+
throw new Error("invalid base32 in TOTP secret");
|
|
138
|
+
value = (value << 5) | idx;
|
|
139
|
+
bits += 5;
|
|
140
|
+
if (bits >= 8) {
|
|
141
|
+
out.push((value >>> (bits - 8)) & 0xff);
|
|
142
|
+
bits -= 8;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return Buffer.from(out);
|
|
146
|
+
}
|
|
147
|
+
static hotp(secret, counter) {
|
|
148
|
+
const buf = Buffer.alloc(8);
|
|
149
|
+
buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
|
|
150
|
+
buf.writeUInt32BE(counter >>> 0, 4);
|
|
151
|
+
const h = crypto_1.default.createHmac("sha1", secret).update(buf).digest();
|
|
152
|
+
const off = h[h.length - 1] & 0x0f;
|
|
153
|
+
const code = ((h[off] & 0x7f) << 24) | ((h[off + 1] & 0xff) << 16)
|
|
154
|
+
| ((h[off + 2] & 0xff) << 8) | (h[off + 3] & 0xff);
|
|
155
|
+
return String(code % 1000000).padStart(6, "0");
|
|
156
|
+
}
|
|
157
|
+
static verifyTotp(login, secretBase32, code) {
|
|
158
|
+
if (!/^\d{6}$/.test(String(code || "").trim()))
|
|
159
|
+
return "TOTP code must be 6 digits";
|
|
160
|
+
let secret;
|
|
161
|
+
try {
|
|
162
|
+
secret = MonitorAuth.base32Decode(secretBase32);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return "TOTP secret is not valid base32";
|
|
166
|
+
}
|
|
167
|
+
const counter = Math.floor(Date.now() / 1000 / 30);
|
|
168
|
+
const given = String(code).trim();
|
|
169
|
+
for (const c of [counter, counter - 1, counter + 1]) {
|
|
170
|
+
const expected = MonitorAuth.hotp(secret, c);
|
|
171
|
+
const a = Buffer.from(expected), b = Buffer.from(given);
|
|
172
|
+
if (a.length === b.length && crypto_1.default.timingSafeEqual(a, b)) {
|
|
173
|
+
const last = MonitorAuth.usedTotpCounter.get(login);
|
|
174
|
+
if (last !== undefined && c <= last)
|
|
175
|
+
return "TOTP code already used";
|
|
176
|
+
MonitorAuth.usedTotpCounter.set(login, c);
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return "TOTP code is invalid";
|
|
181
|
+
}
|
|
182
|
+
static generateTotpSecret(login, issuer = "Monitor") {
|
|
183
|
+
const A = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
184
|
+
const raw = crypto_1.default.randomBytes(20);
|
|
185
|
+
let secret = "";
|
|
186
|
+
for (let i = 0; i < raw.length; i++)
|
|
187
|
+
secret += A[raw[i] % 32];
|
|
188
|
+
const safeIssuer = String(issuer || "").replace(/[:\u0000-\u001f]/g, " ").trim() || "Monitor";
|
|
189
|
+
const label = encodeURIComponent(`${safeIssuer}:${login}`);
|
|
190
|
+
const uri = `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(safeIssuer)}&algorithm=SHA1&digits=6&period=30`;
|
|
191
|
+
return { secret, uri };
|
|
192
|
+
}
|
|
193
|
+
static lockoutRemainingMs(login) {
|
|
194
|
+
const f = MonitorAuth.failures.get(login);
|
|
195
|
+
if (!f || f.lockedUntil <= Date.now())
|
|
196
|
+
return 0;
|
|
197
|
+
return f.lockedUntil - Date.now();
|
|
198
|
+
}
|
|
199
|
+
static registerFailure(login) {
|
|
200
|
+
const f = MonitorAuth.failures.get(login) ?? { count: 0, lockedUntil: 0 };
|
|
201
|
+
f.count++;
|
|
202
|
+
if (f.count >= MonitorAuth.MAX_FAILED_ATTEMPTS) {
|
|
203
|
+
f.lockedUntil = Date.now() + MonitorAuth.LOCKOUT_MS;
|
|
204
|
+
f.count = 0;
|
|
205
|
+
}
|
|
206
|
+
MonitorAuth.failures.set(login, f);
|
|
207
|
+
}
|
|
208
|
+
static clearFailures(login) {
|
|
209
|
+
MonitorAuth.failures.delete(login);
|
|
210
|
+
}
|
|
211
|
+
static sameSecret(a, b) {
|
|
212
|
+
const x = Buffer.from(String(a), "utf-8"), y = Buffer.from(String(b), "utf-8");
|
|
213
|
+
return x.length === y.length && crypto_1.default.timingSafeEqual(x, y);
|
|
214
|
+
}
|
|
215
|
+
static authenticate(users, login, password, totp) {
|
|
216
|
+
const locked = MonitorAuth.lockoutRemainingMs(login);
|
|
217
|
+
if (locked > 0)
|
|
218
|
+
return { error: `account locked, try again in ${Math.ceil(locked / 60000)} min` };
|
|
219
|
+
const all = users || [];
|
|
220
|
+
const candidate = all.find(u => u?.login === login);
|
|
221
|
+
if (!candidate) {
|
|
222
|
+
MonitorAuth.registerFailure(login);
|
|
223
|
+
return { error: "Invalid user" };
|
|
224
|
+
}
|
|
225
|
+
const policy = MonitorAuth.passwordPolicyError(candidate.password);
|
|
226
|
+
if (policy)
|
|
227
|
+
return { error: `password policy violation for this account (${policy})` };
|
|
228
|
+
if (!MonitorAuth.sameSecret(candidate.password, password || "")) {
|
|
229
|
+
MonitorAuth.registerFailure(login);
|
|
230
|
+
return { error: "Invalid user" };
|
|
231
|
+
}
|
|
232
|
+
const secret = MonitorAuth.secretFor(candidate);
|
|
233
|
+
if (!secret) {
|
|
234
|
+
MonitorAuth.clearFailures(login);
|
|
235
|
+
return { user: candidate, enrollmentRequired: true };
|
|
236
|
+
}
|
|
237
|
+
const totpError = MonitorAuth.verifyTotp(login, secret, totp || "");
|
|
238
|
+
if (totpError) {
|
|
239
|
+
MonitorAuth.registerFailure(login);
|
|
240
|
+
return { error: totpError };
|
|
241
|
+
}
|
|
242
|
+
MonitorAuth.clearFailures(login);
|
|
243
|
+
return { user: candidate };
|
|
244
|
+
}
|
|
245
|
+
static pendingEnrollments = new Map();
|
|
246
|
+
static ENROLLMENT_TTL_MS = 10 * 60 * 1000;
|
|
247
|
+
static beginEnrollment(login, issuer = "Monitor") {
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
for (const [t, p] of MonitorAuth.pendingEnrollments)
|
|
250
|
+
if (now - p.createdAt > MonitorAuth.ENROLLMENT_TTL_MS)
|
|
251
|
+
MonitorAuth.pendingEnrollments.delete(t);
|
|
252
|
+
const { secret, uri } = MonitorAuth.generateTotpSecret(login, issuer);
|
|
253
|
+
const token = crypto_1.default.randomBytes(32).toString("hex");
|
|
254
|
+
MonitorAuth.pendingEnrollments.set(token, { login, secret, createdAt: now });
|
|
255
|
+
return { token, secret, uri };
|
|
256
|
+
}
|
|
257
|
+
static completeEnrollment(token, code) {
|
|
258
|
+
const pending = MonitorAuth.pendingEnrollments.get(token);
|
|
259
|
+
if (!pending)
|
|
260
|
+
return { error: "enrollment session expired, start again" };
|
|
261
|
+
if (Date.now() - pending.createdAt > MonitorAuth.ENROLLMENT_TTL_MS) {
|
|
262
|
+
MonitorAuth.pendingEnrollments.delete(token);
|
|
263
|
+
return { error: "enrollment session expired, start again" };
|
|
264
|
+
}
|
|
265
|
+
if (MonitorAuth.enrolled.has(pending.login))
|
|
266
|
+
return { error: "already enrolled" };
|
|
267
|
+
const err = MonitorAuth.verifyTotp(pending.login, pending.secret, code);
|
|
268
|
+
if (err)
|
|
269
|
+
return { error: err };
|
|
270
|
+
MonitorAuth.enrolled.set(pending.login, { secret: pending.secret, enrolledAt: Date.now(), lastCounter: 0 });
|
|
271
|
+
MonitorAuth.pendingEnrollments.delete(token);
|
|
272
|
+
MonitorAuth.saveStore();
|
|
273
|
+
(0, LogService_1.logInfo)(`Monitor 2FA enrolled for "${pending.login}"`, "MonitorAuth");
|
|
274
|
+
return { login: pending.login };
|
|
275
|
+
}
|
|
276
|
+
static purgeSessions(now) {
|
|
277
|
+
for (const [token, s] of MonitorAuth.sessions) {
|
|
278
|
+
if (now - s.lastSeenAt > MonitorAuth.SESSION_IDLE_MS || now - s.createdAt > MonitorAuth.SESSION_MAX_MS)
|
|
279
|
+
MonitorAuth.sessions.delete(token);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
static createSession(login) {
|
|
283
|
+
const now = Date.now();
|
|
284
|
+
MonitorAuth.purgeSessions(now);
|
|
285
|
+
const session = {
|
|
286
|
+
token: crypto_1.default.randomBytes(32).toString("hex"),
|
|
287
|
+
login, createdAt: now, lastSeenAt: now,
|
|
288
|
+
};
|
|
289
|
+
MonitorAuth.sessions.set(session.token, session);
|
|
290
|
+
return session;
|
|
291
|
+
}
|
|
292
|
+
static touchSession(token) {
|
|
293
|
+
const now = Date.now();
|
|
294
|
+
MonitorAuth.purgeSessions(now);
|
|
295
|
+
const s = MonitorAuth.sessions.get(token);
|
|
296
|
+
if (!s)
|
|
297
|
+
return null;
|
|
298
|
+
s.lastSeenAt = now;
|
|
299
|
+
return s;
|
|
300
|
+
}
|
|
301
|
+
static revokeSession(token) {
|
|
302
|
+
MonitorAuth.sessions.delete(token);
|
|
303
|
+
}
|
|
304
|
+
static revokeAllFor(login) {
|
|
305
|
+
for (const [token, s] of MonitorAuth.sessions)
|
|
306
|
+
if (s.login === login)
|
|
307
|
+
MonitorAuth.sessions.delete(token);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
exports.MonitorAuth = MonitorAuth;
|