Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
2591 lines (2367 loc) · 126 KB
/
Copy pathserver.js
File metadata and controls
2591 lines (2367 loc) · 126 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session);
const sqlite3 = require('sqlite3').verbose();
const multer = require('multer');
const bcrypt = require('bcryptjs');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const nodemailer = require('nodemailer');
const zlib = require('zlib');
const { Readable, Transform } = require('stream');
const { pipeline } = require('stream/promises');
const { logRuntime, logSmtp } = require('./logger');
const certificates = require('./tls');
const ENCRYPTION_KEY = crypto.scryptSync(process.env.SESSION_SECRET || 'iso-compliance-dev-secret-change-me', 'salt', 32);
const IV_LENGTH = 16;
// The mail password is stored with AES-256-GCM, which also detects tampering.
// Values saved by older versions (AES-256-CBC, "iv:body") are still read, and
// are written back in the new form the next time the settings are saved.
function encryptSmtpPass(text) {
if (!text) return text;
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
const body = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
return ['v2', iv.toString('hex'), cipher.getAuthTag().toString('hex'), body.toString('hex')].join(':');
}
function decryptSmtpPass(text) {
if (!text) return text;
try {
const parts = text.split(':');
// Saved before encryption existed: still plain text.
if (parts.length === 1) return text;
if (parts[0] === 'v2' && parts.length === 4) {
const tag = Buffer.from(parts[2], 'hex');
if (tag.length !== 16) return '';
const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, Buffer.from(parts[1], 'hex'), { authTagLength: 16 });
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(Buffer.from(parts[3], 'hex')), decipher.final()]).toString('utf8');
}
if (parts.length === 2) {
const decipher = crypto.createDecipheriv('aes-256-cbc', ENCRYPTION_KEY, Buffer.from(parts[0], 'hex'));
return Buffer.concat([decipher.update(Buffer.from(parts[1], 'hex')), decipher.final()]).toString();
}
return '';
} catch (e) {
// Altered, or written with a different key: the password is gone, not usable.
return '';
}
}
process.on('uncaughtException', (err) => {
logRuntime('error', 'Uncaught Exception', err);
});
process.on('unhandledRejection', (reason, promise) => {
logRuntime('error', 'Unhandled Rejection at Promise', reason);
});
const app = express();
const PORT = process.env.PORT || 3000;
const isProd = process.env.NODE_ENV === 'production';
if (isProd && (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === 'change-me-to-a-long-random-string')) {
console.error('FATAL: SESSION_SECRET must be set to a strong value in production. See .env.example.');
process.exit(1);
}
// Setup storage paths (override via env to point at a persistent volume in prod)
const uploadsDir = path.resolve(process.env.UPLOADS_DIR || path.join(__dirname, 'uploads'));
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
const dbPath = path.resolve(process.env.DB_PATH || path.join(__dirname, 'database.sqlite'));
const dataDir = path.dirname(dbPath);
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
// Trust the reverse proxy (Nginx/Caddy/Render) so secure cookies and client
// IP detection (for rate limiting) work behind TLS termination.
app.set('trust proxy', 1);
// One line per request. Line breaks are taken out of the address, so a request
// cannot write lines of its own into the log.
app.use((req, res, next) => {
console.log(`[REQUEST] ${req.method} ${String(req.url).replace(/[\r\n\t]+/g, ' ').slice(0, 300)}`);
next();
});
// Security headers.
app.use(helmet({
// The page uses inline scripts and onclick handlers, so those stay allowed;
// everything else is held to this server: no outside scripts, styles or
// connections, no plugins, and no framing by other sites.
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
scriptSrcAttr: ["'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "blob:"],
mediaSrc: ["'self'", "blob:"],
fontSrc: ["'self'", "data:"],
connectSrc: ["'self'"],
workerSrc: ["'self'", "blob:"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
},
},
// Nothing from another site is ever loaded into the page.
crossOriginEmbedderPolicy: true,
}));
// Aware uses none of the browser's device features, so the page is told it may
// not ask for them - and neither may anything that ever got into it.
app.use((req, res, next) => {
res.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), bluetooth=(), serial=(), midi=(), magnetometer=(), gyroscope=(), accelerometer=(), display-capture=()');
next();
});
// Answers from the API hold personal data and training records: never cached.
app.use('/api', (req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
// A ceiling on how fast one address can call the API, so nobody can tie the
// server up by asking over and over. Far above what a person, or a whole office
// behind one address, does by hand.
app.use('/api', rateLimit({
windowMs: 60 * 1000,
max: Number(process.env.API_RATE_PER_MINUTE) || 1200,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests. Please wait a minute and try again.' }
}));
// Middleware
app.use(express.json({ limit: '400kb' }));
app.use(express.urlencoded({ extended: true }));
// Sessions come before the files, because the course files are only for people
// who are signed in.
const SESSION_COOKIE = 'aware.sid';
app.use(session({
name: SESSION_COOKIE,
store: new SQLiteStore({ db: 'sessions.sqlite', dir: dataDir }),
secret: process.env.SESSION_SECRET || 'iso-compliance-dev-secret-change-me',
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // Aware only ever answers on HTTPS
httpOnly: true, // not readable by client JS
sameSite: 'lax',
path: '/',
maxAge: 1000 * 60 * 60 * 8 // 8 hours
}
}));
// A session is only as good as the account behind it. Somebody who has been
// removed, has had their password reset, or is no longer an administrator loses
// that on their next request, not when the session runs out hours later.
app.use(['/api', '/training'], (req, res, next) => {
if (!req.session.userId) return next();
db.get("SELECT role, require_password_change FROM users WHERE id = ?", [req.session.userId], (err, user) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!user || user.require_password_change) return req.session.regenerate(() => next());
req.session.role = user.role;
next();
});
});
// A change must come from Aware's own pages. The browser says where a request
// was sent from; one sent by another website is refused, whatever cookie it
// carries. Programs that are not browsers send neither header and are let
// through to the sign-in checks as before.
app.use('/api', (req, res, next) => {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const refuse = () => res.status(403).json({ error: 'This request did not come from Offset Aware.' });
const site = req.get('Sec-Fetch-Site');
if (site) return (site === 'same-origin' || site === 'none') ? next() : refuse();
const origin = req.get('Origin');
if (!origin) return next();
let host = null;
try { host = new URL(origin).hostname; } catch { /* not an address: refused below */ }
return host && host === req.hostname ? next() : refuse();
});
// Course files: the videos, their subtitles and their one-page summaries, and
// only for someone who is signed in. Everything else in the training folder -
// the quiz files, which hold the answers, above all - is never sent to a browser.
const COURSE_FILE = /\.(mp4|vtt|summary\.html)$/i;
app.use('/training', (req, res, next) => {
if (!req.session.userId) return res.status(401).json({ error: 'Unauthorized' });
let name = null;
try { name = decodeURIComponent(req.path); } catch { /* refused below */ }
if (!name || !COURSE_FILE.test(name)) return res.status(404).end();
next();
});
app.use(express.static(path.join(__dirname, 'public'), {
etag: false,
maxAge: '0'
}));
// Rate limiter for authentication to slow brute-force attempts.
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: Number(process.env.LOGIN_ATTEMPTS_PER_15_MIN) || 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many login attempts. Please try again later.' }
});
// And per account, wherever the attempts come from: after ten wrong passwords
// for one ID in fifteen minutes that ID has to wait, so guessing from many
// addresses at once gets nowhere either. A right password does not count.
const accountLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: Number(process.env.WRONG_PASSWORDS_PER_15_MIN) || 10,
keyGenerator: (req) => 'id:' + String((req.body && req.body.emp_id) || '').toLowerCase().slice(0, 100),
skipSuccessfulRequests: true,
standardHeaders: false,
legacyHeaders: false,
message: { error: 'Too many wrong passwords for this ID. Please try again in fifteen minutes.' }
});
// New passwords: twelve characters or more.
const MIN_PASSWORD = 12;
const getSettings = () => new Promise((resolve, reject) => {
db.all("SELECT key, value FROM settings", [], (err, rows) => {
if (err) return reject(err);
const settings = {};
rows.forEach(r => settings[r.key] = r.value);
resolve(settings);
});
});
// Returns an SMTP transporter. Uses configured SMTP if smtp_host is set,
// otherwise an Ethereal test account (console preview link only, no real email).
async function getTransporter() {
const settings = await getSettings();
if (settings.smtp_host) {
return {
transporter: nodemailer.createTransport({
host: settings.smtp_host,
port: Number(settings.smtp_port) || 587,
secure: settings.smtp_secure === 'true',
auth: { user: settings.smtp_user, pass: decryptSmtpPass(settings.smtp_pass) },
}),
isEthereal: false,
mailFrom: settings.mail_from || '"Offset Aware" <admin@offset-aware.local>'
};
}
const testAccount = await nodemailer.createTestAccount();
return {
transporter: nodemailer.createTransport({
host: 'smtp.ethereal.email',
port: 587,
secure: false,
auth: { user: testAccount.user, pass: testAccount.pass },
}),
isEthereal: true,
mailFrom: '"Offset Aware (Simulated)" <test@example.com>'
};
}
// The one upload Aware takes: the staff list, as a CSV file, from an
// administrator. It is read from memory and never written to disk, so a list of
// names and email addresses is not left lying in a folder afterwards.
const ALLOWED_UPLOAD_TYPES = new Set(['text/csv', 'application/vnd.ms-excel', 'text/plain']);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024, files: 1 }, // 5 MB, one file
fileFilter: (req, file, cb) => {
if (ALLOWED_UPLOAD_TYPES.has(file.mimetype)) return cb(null, true);
cb(new Error('Unsupported file type. Upload a CSV file.'));
}
});
// An upload too garbled to read is the sender's mistake, and is answered as one.
const staffListUpload = (req, res, next) => upload.single('file')(req, res, (err) => {
if (!err) return next();
if (err.code === 'LIMIT_FILE_SIZE' || err instanceof multer.MulterError || /^Unsupported file type/.test(err.message || '')) return next(err);
res.status(400).json({ error: 'That upload could not be read.' });
});
// Setup SQLite Database
const db = new sqlite3.Database(dbPath);
db.serialize(() => {
// Create Users table
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
emp_id TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
email TEXT,
password TEXT NOT NULL,
role TEXT DEFAULT 'employee',
require_password_change BOOLEAN DEFAULT 0
)`);
// Seamlessly upgrade existing databases
db.run(`ALTER TABLE users ADD COLUMN require_password_change BOOLEAN DEFAULT 0`, (err) => { /* Ignore if exists */ });
// Safe add columns if DB already exists
db.run(`ALTER TABLE users ADD COLUMN email TEXT`, (err) => {});
// Create Training Records table
db.run(`CREATE TABLE IF NOT EXISTS training_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
module_id TEXT,
vendor_name TEXT,
completion_date TEXT,
cert_filename TEXT,
cert_original_name TEXT,
status TEXT DEFAULT 'pending',
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)`);
db.run(`ALTER TABLE training_records ADD COLUMN module_id TEXT`, (err) => {});
// When the person ticked "I confirm..." before submitting the quiz. The
// videos ask for this acknowledgement, and an auditor asks to see it.
db.run(`ALTER TABLE training_records ADD COLUMN acknowledged_at TEXT`, (err) => {});
// The day somebody watched the video again to renew a module that was
// about to run out. Passing the quiz after it starts the new period.
db.run(`ALTER TABLE training_records ADD COLUMN renewal_video_on TEXT`, (err) => {});
// The day the module was last finished, video and quiz both. Training
// runs out a set time after this (Settings, default 12 months).
db.run(`ALTER TABLE training_records ADD COLUMN completed_at TEXT`, (err) => {
// Records finished before this column existed: the best date there is.
db.run(`UPDATE training_records SET completed_at = completion_date
WHERE status = 'completed' AND completed_at IS NULL`);
});
// The day somebody was added, which is when a module they have never
// done falls due. Filled in by the reminders for anybody added before this.
db.run(`ALTER TABLE users ADD COLUMN created_on TEXT`, (err) => {});
// Every reminder sent, to staff or up the chain, and whether it went.
db.run(`CREATE TABLE IF NOT EXISTS reminder_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
module_id TEXT NOT NULL,
due_date TEXT NOT NULL,
kind TEXT NOT NULL,
stage TEXT NOT NULL,
level INTEGER DEFAULT 0,
to_email TEXT,
cc TEXT,
subject TEXT,
sent_on TEXT NOT NULL,
ok INTEGER DEFAULT 1,
error TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Every time somebody finishes a module, kept for good. training_records
// holds only where each person is now; an auditor asks for last year too.
db.run(`CREATE TABLE IF NOT EXISTS training_completions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
module_id TEXT NOT NULL,
completed_on TEXT NOT NULL,
recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)`, () => {
db.run(`INSERT INTO training_completions (user_id, module_id, completed_on)
SELECT tr.user_id, tr.module_id, COALESCE(tr.completed_at, tr.completion_date)
FROM training_records tr
WHERE tr.status = 'completed' AND tr.module_id IS NOT NULL
AND COALESCE(tr.completed_at, tr.completion_date) IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM training_completions c
WHERE c.user_id = tr.user_id AND c.module_id = tr.module_id)`);
});
// Create Settings table
// Every quiz attempt, passed or not, as marked by the server.
db.run(`CREATE TABLE IF NOT EXISTS quiz_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
module_id TEXT NOT NULL,
correct INTEGER NOT NULL,
total INTEGER NOT NULL,
score INTEGER NOT NULL,
passed INTEGER NOT NULL,
attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)`);
db.run(`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)`);
// Who a role module is for, when it is not for everybody: the IT video
// for the IT team, and so on. Chosen by the administrator in Settings.
db.run(`CREATE TABLE IF NOT EXISTS module_assignments (
user_id INTEGER NOT NULL,
module_id TEXT NOT NULL,
PRIMARY KEY (user_id, module_id),
FOREIGN KEY(user_id) REFERENCES users(id)
)`);
// Each person's jobs. A job brings its role courses with it: give someone
// "Branch and call centre" and the branch videos become theirs to do.
db.run(`CREATE TABLE IF NOT EXISTS user_jobs (
user_id INTEGER NOT NULL,
job_id TEXT NOT NULL,
PRIMARY KEY (user_id, job_id),
FOREIGN KEY(user_id) REFERENCES users(id)
)`);
// The frameworks a person was chosen for, when a framework is for chosen
// people rather than everybody: HIPAA for the team that handles health
// data, and nobody else.
db.run(`CREATE TABLE IF NOT EXISTS user_frameworks (
user_id INTEGER NOT NULL,
framework TEXT NOT NULL,
PRIMARY KEY (user_id, framework),
FOREIGN KEY(user_id) REFERENCES users(id)
)`);
});
// Authentication Middleware
const requireAuth = (req, res, next) => {
if (req.session.userId) return next();
res.status(401).json({ error: 'Unauthorized' });
};
const requireAdmin = (req, res, next) => {
if (req.session.userId && req.session.role === 'admin') return next();
res.status(403).json({ error: 'Forbidden: Admin only' });
};
// —— How long training stays valid ——————————————————————
// A finished module counts for this many months, then the person does it
// again. ISO 27001 A.6.3, SAMA 3.1.6 and HIPAA 164.308(a)(5) all expect
// awareness training to be repeated, and yearly is what auditors expect.
// 0 means it never runs out.
const DEFAULT_VALID_MONTHS = 12;
const VALID_MONTH_CHOICES = [0, 6, 12, 24, 36];
function validMonthsFrom(settings) {
const raw = settings.training_valid_months;
if (raw === undefined || raw === null || raw === '') return DEFAULT_VALID_MONTHS;
const n = Number(raw);
return VALID_MONTH_CHOICES.includes(n) ? n : DEFAULT_VALID_MONTHS;
}
const todayIso = () => new Date().toISOString().split('T')[0];
/** The day a module finished on `completedOn` runs out, or null if it never does. */
function expiresOn(completedOn, months) {
if (!completedOn || !months) return null;
const d = new Date(String(completedOn).slice(0, 10) + 'T00:00:00Z');
if (isNaN(d)) return null;
const day = d.getUTCDate();
d.setUTCDate(1);
d.setUTCMonth(d.getUTCMonth() + months);
// 29 February plus a year is 28 February, not 1 March.
const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
d.setUTCDate(Math.min(day, last));
return d.toISOString().split('T')[0];
}
/**
* Where a record stands today. A module finished longer ago than the
* validity period is expired: it is still shown, with its date, but it no
* longer counts and the person is asked to do it again.
*/
function describeRecord(record, months, today = todayIso()) {
if (!record) return null;
const completedOn = record.status === 'completed' ? (record.completed_at || record.completion_date || null) : null;
const expires = completedOn ? expiresOn(completedOn, months) : null;
const expired = !!(expires && expires <= today);
return { ...record, completed_on: completedOn, expires_on: expires, expired, valid: record.status === 'completed' && !expired };
}
// A finished module can be done again in its last RENEW_DAYS, when the
// reminders start. It stays done while the person renews it: watching the
// video is noted, and passing the quiz after that starts the new period.
const RENEW_DAYS = 30;
function renewable(described, today = todayIso()) {
return !!(described && described.valid && described.expires_on &&
(Date.parse(described.expires_on) - Date.parse(today)) / 86400000 <= RENEW_DAYS);
}
// Marks a record finished today and keeps the fact in the history.
function markCompleted(row, userId, moduleId, done) {
const today = todayIso();
db.run("UPDATE training_records SET status = 'completed', cert_filename = 'INTERNAL_COMPLETED', completed_at = ?, renewal_video_on = NULL WHERE id = ?",
[today, row.id], (err) => {
if (err) return done(err);
db.run("INSERT INTO training_completions (user_id, module_id, completed_on) VALUES (?, ?, ?)",
[userId, moduleId, today], () => done(null));
});
}
// --- API ROUTES ---
// Every sign-in starts a session of its own. One that was in the browser
// beforehand - planted there by somebody else, say - is thrown away.
function startSession(req, user, done) {
req.session.regenerate((err) => {
if (err) return done(err);
req.session.userId = user.id;
req.session.role = user.role;
req.session.name = user.name;
done(null);
});
}
// Checked when the ID is not one Aware knows, so a wrong ID takes as long to
// refuse as a wrong password and the two cannot be told apart by timing.
const NO_SUCH_USER_HASH = bcrypt.hashSync(crypto.randomBytes(24).toString('hex'), 10);
// Login
app.post('/api/login', loginLimiter, accountLimiter, (req, res) => {
const emp_id = typeof req.body.emp_id === 'string' ? req.body.emp_id : '';
const password = typeof req.body.password === 'string' ? req.body.password : '';
if (!emp_id || !password) return res.status(400).json({ error: 'ID and Password required' });
db.get("SELECT * FROM users WHERE emp_id = ?", [emp_id], (err, user) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!user) {
bcrypt.compareSync(password, NO_SUCH_USER_HASH);
return res.status(401).json({ error: 'Invalid credentials' });
}
if (bcrypt.compareSync(password, user.password)) {
if (user.require_password_change) {
return res.json({ success: false, requirePasswordChange: true, emp_id: user.emp_id });
}
startSession(req, user, (err) => {
if (err) return res.status(500).json({ error: 'Could not sign in' });
res.json({ success: true, role: user.role, name: user.name });
});
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
});
// First-run setup: while the portal has no administrator, the login page offers
// "Create Admin Account". Once one exists, both routes refuse, so the option can
// never be used to take over a portal that is already running.
app.get('/api/setup/status', (req, res) => {
db.get("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'", [], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ needsAdmin: row.n === 0 });
});
});
app.post('/api/setup/admin', loginLimiter, (req, res) => {
const emp_id = String(req.body.emp_id || '').trim();
const name = String(req.body.name || '').trim();
const email = String(req.body.email || '').trim();
const password = String(req.body.password || '');
if (!emp_id || !name || !email || !password) return res.status(400).json({ error: 'All fields are required' });
if (password.length < MIN_PASSWORD) return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD} characters` });
const hash = bcrypt.hashSync(password, 10);
// One statement, so two people submitting at the same moment cannot both
// become the first administrator.
db.run(`INSERT INTO users (emp_id, name, email, password, role)
SELECT ?, ?, ?, ?, 'admin'
WHERE NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin')`,
[emp_id, name, email, hash],
function(err) {
if (err) return res.status(400).json({ error: 'That ID is already taken' });
if (this.changes === 0) return res.status(409).json({ error: 'An administrator already exists. Please sign in.' });
logRuntime('info', `First administrator created from the login page: ${emp_id}`);
startSession(req, { id: this.lastID, role: 'admin', name }, (err) => {
if (err) return res.status(500).json({ error: 'Created. Please sign in.' });
res.json({ success: true, role: 'admin', name });
});
});
});
// Force Password Change
app.post('/api/force-change-password', loginLimiter, accountLimiter, (req, res) => {
const text = (v) => (typeof v === 'string' ? v : '');
const emp_id = text(req.body.emp_id), current_password = text(req.body.current_password), new_password = text(req.body.new_password);
if (!emp_id || !current_password || !new_password) return res.status(400).json({ error: 'All fields required' });
if (new_password.length < MIN_PASSWORD) return res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD} characters` });
if (new_password === current_password) return res.status(400).json({ error: 'Choose a password different from the temporary one' });
db.get("SELECT * FROM users WHERE emp_id = ?", [emp_id], (err, user) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!user || !bcrypt.compareSync(current_password, user.password)) {
return res.status(401).json({ error: 'Invalid current credentials' });
}
const hash = bcrypt.hashSync(new_password, 10);
db.run("UPDATE users SET password = ?, require_password_change = 0 WHERE id = ?", [hash, user.id], (err) => {
if (err) return res.status(500).json({ error: 'Failed to update password' });
// Automatically log them in
startSession(req, user, (err) => {
if (err) return res.status(500).json({ error: 'Password changed. Please sign in.' });
res.json({ success: true, role: user.role, name: user.name });
});
});
});
});
// Logout
app.post('/api/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie(SESSION_COOKIE, { path: '/' });
res.json({ success: true });
});
});
// Current User Profile
app.get('/api/me', requireAuth, (req, res) => {
db.get("SELECT * FROM users WHERE id = ?", [req.session.userId], (err, user) => {
if (err || !user) return res.status(404).json({ error: 'User not found' });
// Every record, not only the finished ones: the page shows a module
// that has been watched but not yet passed, and lets that person skip
// to the quiz instead of watching the video a second time.
db.all("SELECT * FROM training_records WHERE user_id = ?", [user.id], async (err, records) => {
const settings = await getSettings();
const months = validMonthsFrom(settings);
const reqs = await loadRequirements(settings).catch(() => null);
res.json({
user: { emp_id: user.emp_id, name: user.name, role: user.role, company_id: user.company_id },
records: (records || []).map(r => describeRecord(r, months)),
validMonths: months,
installedModules: installedModules(),
moduleVideos: moduleVideos(),
requiredModules: reqs ? requiredFor(user.id, reqs) : [],
// The courses this server has, for the page's list.
modules: catalog().map((m) => ({
id: m.id, title: m.title, framework: m.framework, installed: !!m.file
})),
version: installedVersion(),
smtpConfigured: !!settings.smtp_host
});
});
});
});
// Record Video Completion (Employee)
app.post('/api/training/video-complete', requireAuth, express.json(), (req, res) => {
const { module_id } = req.body;
if (!module_id) return res.status(400).json({ error: 'Missing module_id' });
const today = todayIso();
getSettings().catch(() => ({})).then((settings) => {
const months = validMonthsFrom(settings);
db.get("SELECT * FROM training_records WHERE user_id = ? AND module_id = ?", [req.session.userId, module_id], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (row) {
const now = describeRecord(row, months);
if (renewable(now)) {
return db.run("UPDATE training_records SET renewal_video_on = ? WHERE id = ?", [today, row.id], (err) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ success: true, status: 'completed', renewal_video_on: today });
});
}
if (now.valid) {
return res.json({ success: true, message: 'Already completed', status: 'completed' });
}
// Run out: this is the next round, and it starts again from here.
if (!now.expired && row.status === 'quiz_completed') {
return markCompleted(row, req.session.userId, module_id, (err) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ success: true, status: 'completed' });
});
}
db.run("UPDATE training_records SET status = 'video_completed', cert_filename = 'VIDEO_ONLY', completion_date = ?, completed_at = NULL WHERE id = ?",
[now.expired ? today : (row.completion_date || today), row.id], function(err) {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ success: true, status: 'video_completed' });
});
} else {
db.run(`INSERT INTO training_records (user_id, module_id, vendor_name, completion_date, cert_filename, cert_original_name, status)
VALUES (?, ?, 'Internal Portal', ?, 'VIDEO_ONLY', 'Video watched', 'video_completed')`,
[req.session.userId, module_id, today],
function(err) {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ success: true, status: 'video_completed' });
});
}
});
});
});
// The pass mark. The server marks every quiz against it.
const QUIZ_PASS_PERCENT = 80;
// —— Which training modules this server has ——————————————————
// The videos are not in the Windows installer: the administrator chooses which
// modules to download while installing, and that is the only time Offset Aware
// uses the internet. A module counts only if its video is here, so nobody is
// chased for a course they cannot take.
// TRAINING_DIR moves the training folder somewhere else, for example a larger
// disk; it is then served at /training like the built-in one.
// The five original videos stay where they shipped, so moving the folder
// does not lose them.
const BUILT_IN_TRAINING_DIR = path.join(__dirname, 'public', 'training');
const TRAINING_DIR = process.env.TRAINING_DIR || BUILT_IN_TRAINING_DIR;
if (process.env.TRAINING_DIR) {
try { fs.mkdirSync(TRAINING_DIR, { recursive: true }); } catch { /* Settings then shows nothing installed */ }
app.use('/training', express.static(TRAINING_DIR, { etag: false, maxAge: '0' }));
}
// The name a video is downloaded under, and the name earlier versions shipped
// it under. Either works, so a file copied in by hand needs no renaming.
const TRAINING_FILES = {
infosec: ['infosec.mp4', 'Infosec.mp4'],
phishing: ['phishing.mp4', 'Phising.mp4'],
privacy: ['privacy.mp4', 'Data Privacy.mp4'],
incident: ['incident.mp4', 'Incident Reporting.mp4'],
secure_coding: ['secure-coding.mp4', 'Secure_Coding_(OWASP).mp4']
};
const REQUIRED_MODULES = ['infosec', 'phishing', 'privacy', 'incident'];
const COURSES_URL = 'https://github.com/offsetsecurity/offset-aware/releases/tag/courses-2';
/** The video file this server has for a module, or null. */
function moduleFile(m) {
for (const dir of new Set([TRAINING_DIR, BUILT_IN_TRAINING_DIR])) {
for (const name of TRAINING_FILES[m]) {
try { if (fs.statSync(path.join(dir, name)).size > 0) return name; } catch { /* not this one */ }
}
}
return null;
}
const MODULE_TITLES = {
infosec: 'Information Security Awareness & AUP',
phishing: 'Phishing & Social Engineering',
privacy: 'Data Privacy & Handling',
incident: 'Incident Reporting',
secure_coding: 'Secure Coding (OWASP)'
};
// —— Course packs ————————————————————————————————————————
// A course in a pack is up to four files in the training folder, named by its
// id: the video (<id>.mp4), its subtitles (<id>.vtt), its quiz and title
// (<id>.quiz.json) and its one-page summary (<id>.summary.html). Copying the
// files in is all it takes; nothing here lists them. A course counts once
// both its quiz and its video are present.
const PACK_ID = /^[a-z0-9][a-z0-9-]{0,40}$/;
let packCache = { stamp: null, list: [] };
function packModules() {
// Read again only when the folder changes: adding or removing a file does that.
let stamp = null;
try { stamp = fs.statSync(TRAINING_DIR).mtimeMs; } catch { /* no folder yet */ }
if (stamp !== null && stamp === packCache.stamp) return packCache.list;
const list = [];
const jobs = [];
let names = [];
try { names = fs.readdirSync(TRAINING_DIR); } catch { /* no folder yet */ }
for (const name of names) {
// A pack's jobs: <pack>.jobs.json lists each job and the role courses it brings.
if (name.endsWith('.jobs.json')) {
try {
const j = JSON.parse(fs.readFileSync(path.join(TRAINING_DIR, name), 'utf8'));
for (const job of Array.isArray(j.jobs) ? j.jobs : []) {
if (!PACK_ID.test(String(job.id)) || !Array.isArray(job.modules)) continue;
jobs.push({
id: job.id,
title: String(job.title || job.id).slice(0, 120),
framework: String(j.framework || 'Other').slice(0, 60),
modules: job.modules.map(String).filter((m) => PACK_ID.test(m))
});
}
} catch { /* a broken jobs file: its jobs are left out */ }
continue;
}
const id = name.endsWith('.quiz.json') ? name.slice(0, -'.quiz.json'.length) : null;
if (!id || !PACK_ID.test(id) || TRAINING_FILES[id]) continue;
try {
const q = JSON.parse(fs.readFileSync(path.join(TRAINING_DIR, name), 'utf8'));
if (!Array.isArray(q.questions) || !q.questions.length) continue;
let video = null;
try { if (fs.statSync(path.join(TRAINING_DIR, id + '.mp4')).size > 0) video = id + '.mp4'; } catch { /* not here */ }
list.push({
id,
title: String(q.title || id).slice(0, 200),
framework: String(q.framework || 'Other').slice(0, 60),
audience: q.audience === 'all' ? 'all' : 'some',
order: Number(q.order) || 999,
file: video
});
} catch { /* a broken quiz file: the course is left out */ }
}
list.sort((a, b) => a.framework.localeCompare(b.framework) || a.order - b.order || a.id.localeCompare(b.id));
packCache = { stamp, list, jobs };
return list;
}
/** The jobs on this server: those with at least one installed course. */
function jobList() {
packModules();
const have = new Set(installedModules());
return (packCache.jobs || [])
.map((j) => ({ ...j, modules: j.modules.filter((m) => have.has(m)) }))
.filter((j) => j.modules.length);
}
/** Every module the server knows: the original five, then the packs. */
function catalog() {
const legacy = Object.keys(TRAINING_FILES).map((id) => ({
id, title: MODULE_TITLES[id], framework: 'Security awareness basics', order: 0,
audience: REQUIRED_MODULES.includes(id) ? 'all' : 'none',
file: moduleFile(id), pack: false
}));
return legacy.concat(packModules().map((m) => ({ ...m, pack: true })));
}
const moduleTitle = (id) => (catalog().find((m) => m.id === id) || {}).title || id;
const isKnownModule = (id) => catalog().some((m) => m.id === id);
function installedModules() {
return catalog().filter((m) => m.file).map((m) => m.id);
}
/** Where the page finds each installed video. */
function moduleVideos() {
const out = {};
for (const m of catalog()) if (m.file) out[m.id] = 'training/' + encodeURIComponent(m.file);
return out;
}
// Who must do each module: 'all' (everybody), 'some' (people whose job brings
// it, or who were chosen for it) or 'none' (anyone may, nobody is chased).
// Saved per module; a module never set uses what its pack says - all-staff
// courses for everybody, role courses by job.
const AUDIENCES = ['all', 'some', 'none'];
function audienceMap(settings) {
let saved = {};
try { saved = JSON.parse(settings.module_audience || '{}') || {}; } catch { saved = {}; }
const out = {};
for (const m of catalog()) out[m.id] = AUDIENCES.includes(saved[m.id]) ? saved[m.id] : m.audience;
return out;
}
// —— Who each framework is for ——————————————————————————
// A framework is one set of courses: the built-in ones, or a pack such as
// HIPAA or SAMA. 'all' - it applies to everybody. 'some' - to the people
// chosen for it, and to anyone holding one of its jobs. 'none' - to nobody;
// the courses can still be taken, but nobody is asked to.
// Every framework starts as 'some', the built-in courses included: nothing is
// handed to the whole company until the administrator says so. Within a
// framework, each module's own setting still decides whether it is for all
// of those people or only for certain jobs.
const SCOPES = ['all', 'some', 'none'];
const DEFAULT_SCOPE = 'some';
/** The frameworks with at least one course on this server. */
function installedFrameworks() {
return [...new Set(catalog().filter((m) => m.file).map((m) => m.framework))];
}
function scopeMap(settings) {
let saved = {};
try { saved = JSON.parse(settings.framework_scope || '{}') || {}; } catch { saved = {}; }
const out = {};
for (const fw of new Set(catalog().map((m) => m.framework))) out[fw] = SCOPES.includes(saved[fw]) ? saved[fw] : DEFAULT_SCOPE;
return out;
}
// A server that had staff before frameworks had a setting gave every
// all-staff course to everybody. It goes on doing so: the frameworks it has
// are recorded as 'all' the first time this runs, and only packs added
// afterwards start as 'some'. Nobody's list of training changes by upgrading.
// A server with no staff yet has nobody to change, and starts the new way.
let scopeSeeded = null;
function seedFrameworkScope() {
if (!scopeSeeded) {
scopeSeeded = (async () => {
if (await dbGetP("SELECT 1 AS x FROM settings WHERE key = 'framework_scope'")) return;
const staff = await dbGetP("SELECT COUNT(*) AS n FROM users WHERE role = 'employee'");
const start = {};
if (staff && staff.n > 0) for (const fw of installedFrameworks()) start[fw] = 'all';
await dbRunP("INSERT OR IGNORE INTO settings (key, value) VALUES ('framework_scope', ?)", [JSON.stringify(start)]);
})().catch((e) => { scopeSeeded = null; throw e; });
}
return scopeSeeded;
}
/** What decides who must do what, read once and used for many people. */
async function loadRequirements(settings) {
await seedFrameworkScope().catch(() => {});
if (!settings || settings.framework_scope === undefined) settings = await getSettings().catch(() => settings || {});
const rows = await dbAll("SELECT user_id, module_id FROM module_assignments");
const jobRows = await dbAll("SELECT user_id, job_id FROM user_jobs");
const frameworkRows = await dbAll("SELECT user_id, framework FROM user_frameworks");
const jobs = jobList();
const jobModules = new Map(jobs.map((j) => [j.id, j.modules]));
const jobFramework = new Map(jobs.map((j) => [j.id, j.framework]));
// A job's courses count as chosen for everyone who has that job, and the
// job puts them in its framework: a SAMA branch job means SAMA applies.
const assigned = new Set(rows.map((r) => r.user_id + '|' + r.module_id));
const members = new Set(frameworkRows.map((r) => r.user_id + '|' + r.framework));
for (const r of jobRows) {
for (const m of jobModules.get(r.job_id) || []) assigned.add(r.user_id + '|' + m);
if (jobFramework.has(r.job_id)) members.add(r.user_id + '|' + jobFramework.get(r.job_id));
}
return {
installed: installedModules(),
audience: audienceMap(settings),
assigned,
scope: scopeMap(settings),
frameworkOf: Object.fromEntries(catalog().map((m) => [m.id, m.framework])),
members
};
}
/** The installed modules this person must finish. */
function requiredFor(userId, req) {
return req.installed.filter((m) => {
const framework = req.frameworkOf[m];
const scope = req.scope[framework] || 'all';
const audience = req.audience[m];
if (scope === 'none' || audience === 'none') return false;
// Chosen for this course by name, or through a job.
if (req.assigned.has(userId + '|' + m)) return true;
return audience === 'all' && (scope === 'all' || req.members.has(userId + '|' + framework));
});
}
/** Each person's jobs, as user id -> list of job ids. */
async function jobsByUser() {
const out = new Map();
for (const r of await dbAll("SELECT user_id, job_id FROM user_jobs")) {
if (!out.has(r.user_id)) out.set(r.user_id, []);
out.get(r.user_id).push(r.job_id);
}
return out;
}
/** Replaces a person's jobs with the known ones in `jobIds`. */
async function setUserJobs(userId, jobIds) {
const known = new Set(jobList().map((j) => j.id));
const keep = [...new Set((jobIds || []).map(String))].filter((j) => known.has(j));
await dbRunP("DELETE FROM user_jobs WHERE user_id = ?", [userId]);
for (const j of keep) await dbRunP("INSERT INTO user_jobs (user_id, job_id) VALUES (?, ?)", [userId, j]);
return keep;
}
/** Replaces the frameworks a person was chosen for with the known ones in `names`. */
async function setUserFrameworks(userId, names) {
const known = new Set(installedFrameworks());
const keep = [...new Set((names || []).map(String))].filter((f) => known.has(f));
await dbRunP("DELETE FROM user_frameworks WHERE user_id = ?", [userId]);
for (const f of keep) await dbRunP("INSERT INTO user_frameworks (user_id, framework) VALUES (?, ?)", [userId, f]);
return keep;
}
/** Replaces the courses a person was chosen for by name with the known ones in `ids`. */
async function setUserModules(userId, ids) {
const known = new Set(installedModules());
const keep = [...new Set((ids || []).map(String))].filter((m) => known.has(m));
await dbRunP("DELETE FROM module_assignments WHERE user_id = ?", [userId]);
for (const m of keep) await dbRunP("INSERT INTO module_assignments (user_id, module_id) VALUES (?, ?)", [userId, m]);
return keep;
}
/**
* A person's training in one go: frameworks, jobs and single courses. Each
* list that is given replaces what they had; one left out is not touched.
*/
async function setUserTraining(userId, training) {
const t = training || {};
if (Array.isArray(t.frameworks)) await setUserFrameworks(userId, t.frameworks);
if (Array.isArray(t.jobs)) await setUserJobs(userId, t.jobs);
if (Array.isArray(t.modules)) await setUserModules(userId, t.modules);
}
/** Adds to, or takes away from, what a person already has. Nothing else of theirs moves. */
async function changeUserTraining(userId, training, remove) {
const t = training || {};
const lists = [
['user_frameworks', 'framework', t.frameworks, new Set(installedFrameworks())],
['user_jobs', 'job_id', t.jobs, new Set(jobList().map((j) => j.id))],
['module_assignments', 'module_id', t.modules, new Set(installedModules())]
];
for (const [table, column, values, known] of lists) {
for (const v of [...new Set((Array.isArray(values) ? values : []).map(String))].filter((x) => known.has(x))) {
await dbRunP(remove
? `DELETE FROM ${table} WHERE user_id = ? AND ${column} = ?`
: `INSERT OR IGNORE INTO ${table} (user_id, ${column}) VALUES (?, ?)`, [userId, v]);
}
}
}
/** Framework names from what a person typed, separated by ; or |. */