notifkit 0.1.7 → 0.1.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/dashboard/README.md +29 -0
- package/dashboard/dist/assets/index-7mvXb4bS.js +310 -0
- package/dashboard/dist/assets/index-o7L-f3-c.css +1 -0
- package/dashboard/dist/favicon.svg +4 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/index.d.mts +447 -9
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{main-CRPxM-O3.mjs → main-4Wyge9ms.mjs} +2 -2
- package/dist/{main-CRPxM-O3.mjs.map → main-4Wyge9ms.mjs.map} +1 -1
- package/dist/{main-Bgi2wWck.mjs → main-AO0JaY5z.mjs} +2 -2
- package/dist/{main-Bgi2wWck.mjs.map → main-AO0JaY5z.mjs.map} +1 -1
- package/dist/{main-DFMHcN_d.mjs → main-BMYSYcv2.mjs} +2 -2
- package/dist/{main-DFMHcN_d.mjs.map → main-BMYSYcv2.mjs.map} +1 -1
- package/dist/{main-Yorxzw0Z.mjs → main-BPbPnBIG.mjs} +2 -2
- package/dist/{main-Yorxzw0Z.mjs.map → main-BPbPnBIG.mjs.map} +1 -1
- package/dist/{main-pFAfCkVX.mjs → main-CyeQ_yDt.mjs} +2 -2
- package/dist/{main-pFAfCkVX.mjs.map → main-CyeQ_yDt.mjs.map} +1 -1
- package/dist/{main-CjbVdUqa.mjs → main-D19Wxs0x.mjs} +2 -2
- package/dist/{main-CjbVdUqa.mjs.map → main-D19Wxs0x.mjs.map} +1 -1
- package/dist/{main-D6SG3Isk.mjs → main-DSmh1e-V.mjs} +2 -2
- package/dist/{main-D6SG3Isk.mjs.map → main-DSmh1e-V.mjs.map} +1 -1
- package/dist/{main-CFSukWm8.mjs → main-JdOvJ70b.mjs} +495 -41
- package/dist/main-JdOvJ70b.mjs.map +1 -0
- package/dist/{src-CPMwsUCJ.mjs → src-CGmXRfsM.mjs} +86 -20
- package/dist/src-CGmXRfsM.mjs.map +1 -0
- package/drizzle/0005_admin_users.sql +11 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +8 -3
- package/scripts/create-admin.mjs +67 -0
- package/src/config/index.ts +12 -0
- package/src/db/schema.ts +13 -0
- package/src/repositories/index.ts +82 -1
- package/src/services/api/admin-static.ts +225 -0
- package/src/services/api/handlers.ts +224 -33
- package/src/services/api/main.ts +183 -29
- package/src/services/api/router.ts +7 -1
- package/src/services/auth/index.ts +111 -0
- package/dist/main-CFSukWm8.mjs.map +0 -1
- package/dist/src-CPMwsUCJ.mjs.map +0 -1
package/src/services/api/main.ts
CHANGED
|
@@ -11,11 +11,14 @@ import {
|
|
|
11
11
|
ProjectRepository,
|
|
12
12
|
WorkflowRepository,
|
|
13
13
|
SegmentRepository,
|
|
14
|
+
AdminUserRepository,
|
|
14
15
|
} from "@/repositories/index.js";
|
|
15
16
|
import { STREAMS } from "@/contracts/index.js";
|
|
16
17
|
import { readJsonBody, readRawBody, sendJson, HttpError } from "./http.js";
|
|
17
18
|
import { Router } from "./router.js";
|
|
18
19
|
import { createHandlers } from "./handlers.js";
|
|
20
|
+
import { handleAdminRequest } from "./admin-static.js";
|
|
21
|
+
import { getAdminSession, hashPassword } from "@/services/auth/index.js";
|
|
19
22
|
import { projects, messageLogs, projectApiKeys, suppressions } from "@/db/schema.js";
|
|
20
23
|
import { eq, inArray } from "drizzle-orm";
|
|
21
24
|
import { randomBytes, timingSafeEqual, createHash } from "node:crypto";
|
|
@@ -58,6 +61,20 @@ export function extractAuthToken(req: Pick<IncomingMessage, "headers">): string
|
|
|
58
61
|
return undefined;
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Resolves client IP address, respecting reverse proxy headers only when TRUST_PROXY is enabled.
|
|
66
|
+
*/
|
|
67
|
+
export function getClientIp(req: IncomingMessage, trustProxy: boolean): string {
|
|
68
|
+
if (trustProxy) {
|
|
69
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
70
|
+
if (typeof forwarded === "string") {
|
|
71
|
+
const first = forwarded.split(",")[0]?.trim();
|
|
72
|
+
if (first) return first;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return req.socket.remoteAddress || "unknown";
|
|
76
|
+
}
|
|
77
|
+
|
|
61
78
|
async function handleCreateProject(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
62
79
|
const parsed = z.object({ name: z.string().min(1) }).safeParse(await readJsonBody(req));
|
|
63
80
|
if (!parsed.success) {
|
|
@@ -127,7 +144,42 @@ async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise
|
|
|
127
144
|
sendJson(res, statusCode, response);
|
|
128
145
|
}
|
|
129
146
|
|
|
130
|
-
async function handleMetrics(
|
|
147
|
+
async function handleMetrics(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
148
|
+
const remoteIp = req.socket.remoteAddress ?? "";
|
|
149
|
+
const isLoopback =
|
|
150
|
+
remoteIp === "127.0.0.1" ||
|
|
151
|
+
remoteIp === "::1" ||
|
|
152
|
+
remoteIp === "::ffff:127.0.0.1" ||
|
|
153
|
+
remoteIp === "localhost";
|
|
154
|
+
|
|
155
|
+
let authorized = isLoopback;
|
|
156
|
+
if (!authorized) {
|
|
157
|
+
const token = extractAuthToken(req);
|
|
158
|
+
if (token) {
|
|
159
|
+
if (token.startsWith("nk_sess_") && redis?.native) {
|
|
160
|
+
const session = await getAdminSession(redis.native, token);
|
|
161
|
+
if (session) authorized = true;
|
|
162
|
+
} else if (config.ADMIN_API_KEY) {
|
|
163
|
+
const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
|
|
164
|
+
const providedBuffer = Buffer.from(token);
|
|
165
|
+
if (
|
|
166
|
+
expectedBuffer.length === providedBuffer.length &&
|
|
167
|
+
timingSafeEqual(expectedBuffer, providedBuffer)
|
|
168
|
+
) {
|
|
169
|
+
authorized = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!authorized) {
|
|
176
|
+
sendJson(res, 401, {
|
|
177
|
+
error: "unauthorized",
|
|
178
|
+
message: "Metrics endpoint requires admin credentials",
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
131
183
|
const registry = getMetricsRegistry();
|
|
132
184
|
res.writeHead(200, { "Content-Type": registry.contentType });
|
|
133
185
|
res.end(await registry.metrics());
|
|
@@ -164,6 +216,15 @@ let server: ReturnType<typeof createServer>;
|
|
|
164
216
|
|
|
165
217
|
export async function startApiServer() {
|
|
166
218
|
logger = createLogger({ name: "api", level: config.LOG_LEVEL });
|
|
219
|
+
|
|
220
|
+
if (config.NODE_ENV === "production" && !config.UNSUBSCRIBE_SECRET) {
|
|
221
|
+
logger.fatal(
|
|
222
|
+
"UNSUBSCRIBE_SECRET is required in production. Without it, all unsubscribe links " +
|
|
223
|
+
"return 400 and opt-outs are silently dropped. Set a random string of 32+ characters and restart.",
|
|
224
|
+
);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
|
|
167
228
|
redis = new RedisClient({ url: config.REDIS_URL, name: "api", logger });
|
|
168
229
|
const dbData = createDatabase({ url: config.DATABASE_URL, applicationName: "api", logger });
|
|
169
230
|
sql = dbData.sql;
|
|
@@ -177,6 +238,26 @@ export async function startApiServer() {
|
|
|
177
238
|
events: new StreamProducer({ redis: redis.native, stream: STREAMS.EVENTS_INBOUND, logger }),
|
|
178
239
|
};
|
|
179
240
|
|
|
241
|
+
const adminUserRepo = new AdminUserRepository(db);
|
|
242
|
+
|
|
243
|
+
if (config.ADMIN_EMAIL && config.ADMIN_PASSWORD) {
|
|
244
|
+
try {
|
|
245
|
+
const existing = await adminUserRepo.findByEmailOrUsername(config.ADMIN_EMAIL);
|
|
246
|
+
if (!existing) {
|
|
247
|
+
const passwordHash = await hashPassword(config.ADMIN_PASSWORD);
|
|
248
|
+
await adminUserRepo.create({
|
|
249
|
+
email: config.ADMIN_EMAIL,
|
|
250
|
+
username: config.ADMIN_USERNAME,
|
|
251
|
+
passwordHash,
|
|
252
|
+
role: "superadmin",
|
|
253
|
+
});
|
|
254
|
+
logger.info({ email: config.ADMIN_EMAIL }, "Bootstrapped initial admin user");
|
|
255
|
+
}
|
|
256
|
+
} catch (err) {
|
|
257
|
+
logger.error({ err }, "Failed to bootstrap default admin user");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
180
261
|
deps = {
|
|
181
262
|
logger,
|
|
182
263
|
redis,
|
|
@@ -187,12 +268,16 @@ export async function startApiServer() {
|
|
|
187
268
|
projectRepo: new ProjectRepository(db),
|
|
188
269
|
workflowRepo: new WorkflowRepository(db),
|
|
189
270
|
segmentRepo: new SegmentRepository(db),
|
|
271
|
+
adminUserRepo,
|
|
190
272
|
db,
|
|
191
273
|
};
|
|
192
274
|
h = createHandlers(deps);
|
|
193
275
|
|
|
194
276
|
router = new Router();
|
|
195
277
|
router
|
|
278
|
+
.post("/v1/auth/login", h.login)
|
|
279
|
+
.post("/v1/auth/logout", h.logout)
|
|
280
|
+
.get("/v1/auth/me", h.getMe)
|
|
196
281
|
.put("/v1/templates", h.syncTemplates)
|
|
197
282
|
.get("/v1/templates", h.listTemplates)
|
|
198
283
|
.get("/v1/templates/:id", h.getTemplate)
|
|
@@ -253,8 +338,28 @@ export async function startApiServer() {
|
|
|
253
338
|
server.keepAliveTimeout = 5_000;
|
|
254
339
|
|
|
255
340
|
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
256
|
-
|
|
257
|
-
|
|
341
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
342
|
+
const origin = req.headers["origin"] as string | undefined;
|
|
343
|
+
const allowedOrigins = (config.CORS_ORIGIN ?? "")
|
|
344
|
+
.split(",")
|
|
345
|
+
.map((s) => s.trim())
|
|
346
|
+
.filter(Boolean);
|
|
347
|
+
const isSessionRoute =
|
|
348
|
+
url.pathname.startsWith("/v1/auth/") ||
|
|
349
|
+
url.pathname === "/v1/projects" ||
|
|
350
|
+
url.pathname.startsWith("/v1/projects/") ||
|
|
351
|
+
url.pathname.startsWith("/v1/system/") ||
|
|
352
|
+
url.pathname.startsWith("/v1/dlq");
|
|
353
|
+
|
|
354
|
+
if (isSessionRoute) {
|
|
355
|
+
if (origin && allowedOrigins.includes(origin)) {
|
|
356
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
357
|
+
res.setHeader("Vary", "Origin");
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
361
|
+
}
|
|
362
|
+
|
|
258
363
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
259
364
|
res.setHeader(
|
|
260
365
|
"Access-Control-Allow-Headers",
|
|
@@ -267,11 +372,15 @@ export async function startApiServer() {
|
|
|
267
372
|
return;
|
|
268
373
|
}
|
|
269
374
|
|
|
270
|
-
|
|
375
|
+
if (url.pathname === "/admin" || url.pathname.startsWith("/admin/")) {
|
|
376
|
+
const handled = await handleAdminRequest(req, res, url);
|
|
377
|
+
if (handled) return;
|
|
378
|
+
}
|
|
271
379
|
|
|
272
380
|
let projectId: string | undefined = undefined;
|
|
273
381
|
let projectRateLimitRpm = 600;
|
|
274
382
|
let keyRole: "admin" | "read_only" = "admin";
|
|
383
|
+
let isAdminToken = false;
|
|
275
384
|
|
|
276
385
|
const isProjectManagement =
|
|
277
386
|
url.pathname === "/v1/projects" || url.pathname.startsWith("/v1/projects/");
|
|
@@ -280,8 +389,14 @@ export async function startApiServer() {
|
|
|
280
389
|
// will. The signed token in the URL is the credential, and it authorises
|
|
281
390
|
// exactly one action for one address.
|
|
282
391
|
const isPublicUnsubscribe = url.pathname === "/v1/unsubscribe";
|
|
283
|
-
|
|
284
|
-
|
|
392
|
+
const isPublicAuth = url.pathname === "/v1/auth/login";
|
|
393
|
+
|
|
394
|
+
if (
|
|
395
|
+
url.pathname.startsWith("/v1/") &&
|
|
396
|
+
!isProjectManagement &&
|
|
397
|
+
!isPublicUnsubscribe &&
|
|
398
|
+
!isPublicAuth
|
|
399
|
+
) {
|
|
285
400
|
let token = extractAuthToken(req);
|
|
286
401
|
if (!token && url.searchParams.has("token")) {
|
|
287
402
|
token = url.searchParams.get("token") || undefined;
|
|
@@ -292,8 +407,13 @@ export async function startApiServer() {
|
|
|
292
407
|
return;
|
|
293
408
|
}
|
|
294
409
|
|
|
295
|
-
|
|
296
|
-
|
|
410
|
+
if (token.startsWith("nk_sess_")) {
|
|
411
|
+
const session = await getAdminSession(redis.native, token);
|
|
412
|
+
if (session) {
|
|
413
|
+
isAdminToken = true;
|
|
414
|
+
keyRole = session.role === "superadmin" ? "admin" : (session.role as any);
|
|
415
|
+
}
|
|
416
|
+
} else if (config.ADMIN_API_KEY) {
|
|
297
417
|
const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
|
|
298
418
|
const providedBuffer = Buffer.from(token);
|
|
299
419
|
if (
|
|
@@ -309,7 +429,11 @@ export async function startApiServer() {
|
|
|
309
429
|
(req.headers["x-project-id"] as string | undefined) ||
|
|
310
430
|
url.searchParams.get("projectId") ||
|
|
311
431
|
undefined;
|
|
312
|
-
|
|
432
|
+
const isProjectAgnostic =
|
|
433
|
+
url.pathname.startsWith("/v1/auth/") ||
|
|
434
|
+
url.pathname.startsWith("/v1/system/") ||
|
|
435
|
+
url.pathname.startsWith("/v1/dlq");
|
|
436
|
+
if (!headerProjectId && !isProjectAgnostic) {
|
|
313
437
|
sendJson(res, 400, {
|
|
314
438
|
error: "bad_request",
|
|
315
439
|
message: "x-project-id header or projectId query param required when using admin token",
|
|
@@ -318,7 +442,9 @@ export async function startApiServer() {
|
|
|
318
442
|
}
|
|
319
443
|
projectId = headerProjectId;
|
|
320
444
|
projectRateLimitRpm = 6000;
|
|
321
|
-
|
|
445
|
+
if (!token.startsWith("nk_sess_")) {
|
|
446
|
+
keyRole = "admin";
|
|
447
|
+
}
|
|
322
448
|
} else {
|
|
323
449
|
const tokenHash = createHash("sha256").update(token).digest("hex");
|
|
324
450
|
const cached = authCache.get(tokenHash);
|
|
@@ -374,7 +500,7 @@ export async function startApiServer() {
|
|
|
374
500
|
end
|
|
375
501
|
return -1
|
|
376
502
|
`;
|
|
377
|
-
const rlKey = `rate-limit:api:req:${projectId}`;
|
|
503
|
+
const rlKey = `rate-limit:api:req:${projectId || "global"}`;
|
|
378
504
|
const nowMs = Date.now();
|
|
379
505
|
const count = (await redis.native.eval(
|
|
380
506
|
LUA_LIMIT,
|
|
@@ -398,27 +524,31 @@ export async function startApiServer() {
|
|
|
398
524
|
} else if (isProjectManagement) {
|
|
399
525
|
const token = extractAuthToken(req);
|
|
400
526
|
|
|
401
|
-
if (!config.ADMIN_API_KEY) {
|
|
402
|
-
sendJson(res, 403, {
|
|
403
|
-
error: "forbidden",
|
|
404
|
-
message: "Project management disabled (no ADMIN_API_KEY set)",
|
|
405
|
-
});
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
527
|
if (!token) {
|
|
409
528
|
sendJson(res, 401, { error: "unauthorized", message: "Missing admin token" });
|
|
410
529
|
return;
|
|
411
530
|
}
|
|
412
531
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
532
|
+
let authorized = false;
|
|
533
|
+
if (token.startsWith("nk_sess_")) {
|
|
534
|
+
const session = await getAdminSession(redis.native, token);
|
|
535
|
+
if (session) authorized = true;
|
|
536
|
+
} else if (config.ADMIN_API_KEY) {
|
|
537
|
+
const expectedBuffer = Buffer.from(config.ADMIN_API_KEY);
|
|
538
|
+
const providedBuffer = Buffer.from(token);
|
|
539
|
+
if (
|
|
540
|
+
expectedBuffer.length === providedBuffer.length &&
|
|
541
|
+
timingSafeEqual(expectedBuffer, providedBuffer)
|
|
542
|
+
) {
|
|
543
|
+
authorized = true;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (!authorized) {
|
|
419
548
|
sendJson(res, 401, { error: "unauthorized", message: "Invalid admin token" });
|
|
420
549
|
return;
|
|
421
550
|
}
|
|
551
|
+
isAdminToken = true;
|
|
422
552
|
}
|
|
423
553
|
|
|
424
554
|
// The unsubscribe routes skip the block above, and with it the per-project
|
|
@@ -426,10 +556,7 @@ export async function startApiServer() {
|
|
|
426
556
|
// an open endpoint cannot be used to hammer the process. Generous, because
|
|
427
557
|
// a shared corporate egress IP can legitimately produce a burst.
|
|
428
558
|
if (isPublicUnsubscribe) {
|
|
429
|
-
const clientIp =
|
|
430
|
-
(req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ||
|
|
431
|
-
req.socket.remoteAddress ||
|
|
432
|
-
"unknown";
|
|
559
|
+
const clientIp = getClientIp(req, Boolean(config.TRUST_PROXY));
|
|
433
560
|
try {
|
|
434
561
|
const key = `rate-limit:api:unsub:${clientIp}`;
|
|
435
562
|
const count = await redis.native.incr(key);
|
|
@@ -446,6 +573,27 @@ export async function startApiServer() {
|
|
|
446
573
|
}
|
|
447
574
|
}
|
|
448
575
|
|
|
576
|
+
// The login route is unauthenticated and CPU-intensive due to password hashing (scrypt).
|
|
577
|
+
// Throttle login attempts per IP to prevent credential brute-forcing and CPU exhaustion.
|
|
578
|
+
if (isPublicAuth) {
|
|
579
|
+
const clientIp = getClientIp(req, Boolean(config.TRUST_PROXY));
|
|
580
|
+
try {
|
|
581
|
+
const key = `rate-limit:api:auth:${clientIp}`;
|
|
582
|
+
const count = await redis.native.incr(key);
|
|
583
|
+
if (count === 1) await redis.native.expire(key, 60);
|
|
584
|
+
if (count > 10) {
|
|
585
|
+
res.setHeader("Retry-After", "60");
|
|
586
|
+
sendJson(res, 429, {
|
|
587
|
+
error: "too_many_requests",
|
|
588
|
+
message: "Too many login attempts. Please try again later.",
|
|
589
|
+
});
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
} catch (err) {
|
|
593
|
+
logger.warn({ err }, "auth rate limit unavailable — allowing request");
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
449
597
|
const route = router.match(req.method ?? "GET", url.pathname);
|
|
450
598
|
|
|
451
599
|
if (!route) {
|
|
@@ -453,7 +601,13 @@ export async function startApiServer() {
|
|
|
453
601
|
return;
|
|
454
602
|
}
|
|
455
603
|
|
|
456
|
-
const ctx = {
|
|
604
|
+
const ctx = {
|
|
605
|
+
params: route.params,
|
|
606
|
+
query: url.searchParams,
|
|
607
|
+
projectId,
|
|
608
|
+
role: keyRole,
|
|
609
|
+
isAdmin: isAdminToken,
|
|
610
|
+
};
|
|
457
611
|
|
|
458
612
|
void Promise.resolve(route.handler(req, res, ctx)).catch((err: unknown) => {
|
|
459
613
|
if (err instanceof HttpError) {
|
|
@@ -5,6 +5,7 @@ export interface RouteContext {
|
|
|
5
5
|
query: URLSearchParams;
|
|
6
6
|
projectId?: string;
|
|
7
7
|
role?: "admin" | "read_only";
|
|
8
|
+
isAdmin?: boolean;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export type RouteHandler = (
|
|
@@ -65,7 +66,12 @@ export class Router {
|
|
|
65
66
|
const seg = route.segments[i]!;
|
|
66
67
|
const part = parts[i]!;
|
|
67
68
|
if (seg.startsWith(":")) {
|
|
68
|
-
|
|
69
|
+
try {
|
|
70
|
+
params[seg.slice(1)] = decodeURIComponent(part);
|
|
71
|
+
} catch {
|
|
72
|
+
ok = false;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
69
75
|
} else if (seg !== part) {
|
|
70
76
|
ok = false;
|
|
71
77
|
break;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import type { RedisClient } from "@/redis/index.js";
|
|
4
|
+
import type { AdminUserRecord } from "@/repositories/index.js";
|
|
5
|
+
|
|
6
|
+
const scryptAsync = promisify(scrypt);
|
|
7
|
+
|
|
8
|
+
const DEFAULT_SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
|
|
9
|
+
const SESSION_PREFIX = "notif:session:";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Pre-computed dummy hash conforming to salt(16 bytes hex):hash(64 bytes hex).
|
|
13
|
+
* Used during authentication failure to equalize execution time and prevent
|
|
14
|
+
* user enumeration via scrypt timing side-channels.
|
|
15
|
+
*/
|
|
16
|
+
export const DUMMY_PASSWORD_HASH =
|
|
17
|
+
"00000000000000000000000000000000:00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
|
|
18
|
+
|
|
19
|
+
export interface AdminSession {
|
|
20
|
+
token: string;
|
|
21
|
+
adminId: string;
|
|
22
|
+
email: string;
|
|
23
|
+
username: string | null;
|
|
24
|
+
role: string;
|
|
25
|
+
createdAt: number;
|
|
26
|
+
expiresAt: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Hashes a plaintext password using crypto.scrypt with a random 16-byte salt.
|
|
31
|
+
* Returns salt and hash separated by a colon.
|
|
32
|
+
*/
|
|
33
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
34
|
+
const salt = randomBytes(16).toString("hex");
|
|
35
|
+
const derivedKey = (await scryptAsync(password, salt, 64)) as Buffer;
|
|
36
|
+
return `${salt}:${derivedKey.toString("hex")}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Verifies a plaintext password against a stored salt:hash string using constant-time comparison.
|
|
41
|
+
*/
|
|
42
|
+
export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
|
|
43
|
+
const [salt, key] = storedHash.split(":");
|
|
44
|
+
if (!salt || !key) return false;
|
|
45
|
+
|
|
46
|
+
const keyBuffer = Buffer.from(key, "hex");
|
|
47
|
+
const derivedKey = (await scryptAsync(password, salt, keyBuffer.length)) as Buffer;
|
|
48
|
+
|
|
49
|
+
if (derivedKey.length !== keyBuffer.length) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return timingSafeEqual(derivedKey, keyBuffer);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Creates a secure session in Redis for an authenticated admin user.
|
|
57
|
+
*/
|
|
58
|
+
export async function createAdminSession(
|
|
59
|
+
redis: RedisClient["native"],
|
|
60
|
+
user: AdminUserRecord,
|
|
61
|
+
ttlSeconds: number = DEFAULT_SESSION_TTL_SECONDS,
|
|
62
|
+
): Promise<AdminSession> {
|
|
63
|
+
const token = `nk_sess_${randomBytes(32).toString("hex")}`;
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
const session: AdminSession = {
|
|
66
|
+
token,
|
|
67
|
+
adminId: user.id,
|
|
68
|
+
email: user.email,
|
|
69
|
+
username: user.username,
|
|
70
|
+
role: user.role,
|
|
71
|
+
createdAt: now,
|
|
72
|
+
expiresAt: now + ttlSeconds * 1000,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
await redis.set(`${SESSION_PREFIX}${token}`, JSON.stringify(session), "EX", ttlSeconds);
|
|
76
|
+
return session;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Retrieves an admin session from Redis if valid and unexpired.
|
|
81
|
+
*/
|
|
82
|
+
export async function getAdminSession(
|
|
83
|
+
redis: RedisClient["native"],
|
|
84
|
+
token: string,
|
|
85
|
+
): Promise<AdminSession | null> {
|
|
86
|
+
if (!token || !token.startsWith("nk_sess_")) return null;
|
|
87
|
+
const raw = await redis.get(`${SESSION_PREFIX}${token}`);
|
|
88
|
+
if (!raw) return null;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const session = JSON.parse(raw) as AdminSession;
|
|
92
|
+
if (session.expiresAt <= Date.now()) {
|
|
93
|
+
await redis.del(`${SESSION_PREFIX}${token}`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return session;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Revokes an active admin session.
|
|
104
|
+
*/
|
|
105
|
+
export async function revokeAdminSession(
|
|
106
|
+
redis: RedisClient["native"],
|
|
107
|
+
token: string,
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
if (!token) return;
|
|
110
|
+
await redis.del(`${SESSION_PREFIX}${token}`);
|
|
111
|
+
}
|