frugal-iot-server 0.3.7 → 0.3.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.
@@ -127,12 +127,36 @@ import sqlite3 from 'sqlite3'; // https://www.npmjs.com/package/sqlite3
127
127
  import crypto from 'crypto'; /* https://nodejs.org/api/crypto.html */
128
128
  // import cookieParser from 'cookie-parser'; // https://www.npmjs.com/package/cookie-parser (note comment on https://www.npmjs.com/package/express-session that not needed and conflicts with session)
129
129
  import {waterfall, each} from 'async';
130
+ import { mailInit, mailConfigured, sendMail } from './lib/mailer.js';
131
+ import { resetCodeMake, resetCodeCheck, resetRateOk } from './lib/resetcode.js';
130
132
  // import { openDB } from 'sqlite-express-package'; /* appContent, appSelect, validateId, validateAlias, tagCloud, atom, rss,*/
131
133
 
132
134
  export let config; // Live binding - lib/api-routes.js reads the current value at request time
133
135
  let mqttLogger = new MqttLogger();
134
136
  const loginUrl = '/dashboard/login.html';
135
137
 
138
+ // Back to the login page with something to say. A form POST can only be answered with a redirect
139
+ // and there is no session to hang a message on, so it all goes in the query string, where
140
+ // mqtt-login reads it as attributes. "mode" is which of the four forms to show;
141
+ // "messagetype" is error or info, which is the difference between a red box and a green one.
142
+ function loginRedirect(res, { mode = 'signin', message, messagetype, url }) {
143
+ const q = new URLSearchParams({ mode });
144
+ if (message) { q.set('message', message); }
145
+ if (messagetype) { q.set('messagetype', messagetype); }
146
+ if (url) { q.set('url', url); } // Encoded here: a return url usually has a query string of its own
147
+ res.redirect(`${loginUrl}?${q}`);
148
+ }
149
+ // A password reset link carries its token in the query string, and everything that logs a URL goes
150
+ // to the journal, which outlives the ten minutes the token is good for.
151
+ function redactUrl(url) {
152
+ return String(url).replace(/([?&]code=)[^&]*/, '$1REDACTED');
153
+ }
154
+ // Where this server can be reached from outside, for the link in a reset email. Behind a proxy that
155
+ // does not set X-Forwarded-Host there is nothing in the request to go on, hence the config override.
156
+ function publicBase(req) {
157
+ return (config.email && config.email.baseurl) || `${req.protocol}://${req.get('host')}`;
158
+ }
159
+
136
160
  const optionsHeaders = {
137
161
  'Access-Control-Allow-Origin': '*',
138
162
  // Probably Needs: GET, OPTIONS HEAD, do not believe can do POST, PUT, DELETE yet but could be wrong about that.
@@ -255,6 +279,13 @@ function send_people_list(req, res) {
255
279
  });
256
280
  }
257
281
 
282
+ // Sessions older than this hold that user's permissions from before the change - see the middleware
283
+ // after passport.authenticate('session')
284
+ const permissionsChangedAt = new Map(); // user id -> when it last changed
285
+ function notePermissionsChanged(id) {
286
+ permissionsChangedAt.set(Number(id), Date.now());
287
+ }
288
+
258
289
  function add_permission(id, capability, org, cb) {
259
290
  if ((id === undefined)
260
291
  || (capability === undefined) || (capability.length < 2)
@@ -267,6 +298,7 @@ function add_permission(id, capability, org, cb) {
267
298
  (cb) => db.get('SELECT COUNT(id) FROM permissions WHERE id = ? AND capability = ? AND org = ?', [id, capability, org], cb),
268
299
  (n_perms, cb) => { if (n_perms["COUNT(id)"] != 0) { cb(new Error("Duplicate permission")); } else { cb(null); }},
269
300
  (cb) => db.run(sqlAddPermission, [id, capability, org], cb),
301
+ (cb) => { notePermissionsChanged(id); cb(null); },
270
302
  ], cb);
271
303
  }
272
304
  }
@@ -278,6 +310,7 @@ function permissions_delete(id, capability, org, cb) {
278
310
  } else {
279
311
  waterfall([
280
312
  (cb) => db.get('DELETE FROM permissions WHERE id = ? AND capability = ? AND org = ?', [id,capability,org], cb),
313
+ (cb) => { notePermissionsChanged(id); cb(null); },
281
314
  ], cb);
282
315
  }
283
316
  }
@@ -450,10 +483,20 @@ function execSqlStart(cb) {
450
483
  });
451
484
  }
452
485
 
486
+ // Look somebody up by whatever they typed into the one "Username or email" box on the login page.
487
+ // Username first, so that a username with an "@" in it still works, then email - which is not
488
+ // declared UNIQUE in the schema, so take the first match rather than assuming there is only one.
489
+ function findUserByLogin(login, cb) {
490
+ db.get('SELECT * FROM users WHERE username = ?', [ login ], function(err, user) {
491
+ if (err || user || !String(login || '').includes('@')) { return cb(err, user); }
492
+ db.get('SELECT * FROM users WHERE email = ? COLLATE NOCASE', [ login ], cb);
493
+ });
494
+ }
495
+
453
496
  // This verifies the user, and if successful returns a data structure via cb
454
497
  // see passport.authenticate('local'... for where it gets used
455
498
  passport.use(new LocalStrategy(function verify(username, password, cb) {
456
- db.get('SELECT * FROM users WHERE username = ?', [ username ], function(err, user) {
499
+ findUserByLogin(username, function(err, user) {
457
500
  if (err) { return cb(err); }
458
501
  if (!user) { return cb(null, false, { message: 'Incorrect username or password+' }); }
459
502
  // TODO- - maybe just import pbkdf2 and timingSafeEqual ?
@@ -485,7 +528,8 @@ function loggedInOrRedirect(req, res, next) {
485
528
  next();
486
529
  } else {
487
530
  // If originalUrl is /private/index.html then req.url is just /index.html
488
- res.redirect(307, `${loginUrl}?register=false&message=Please%20login&url=` + req.originalUrl);
531
+ const q = new URLSearchParams({ mode: 'signin', message: 'Please login', url: req.originalUrl });
532
+ res.redirect(307, `${loginUrl}?${q}`);
489
533
  }
490
534
  }
491
535
  function hasPermissions(user, org, permission) {
@@ -567,8 +611,9 @@ function shouldIBeLoggedIn(req, res, next) {
567
611
  // intact), encoded so its own query string can't corrupt this redirect's query string. Carry "lang"
568
612
  // over as its own top-level param (from req.query, not duplicated into "url") so login.html itself
569
613
  // renders in the right language.
570
- const langParam = req.query.lang ? `&lang=${encodeURIComponent(req.query.lang)}` : '';
571
- res.redirect(307, `${loginUrl}?register=false&message=Please%20login${langParam}&url=${encodeURIComponent(req.originalUrl)}`);
614
+ const q = new URLSearchParams({ mode: 'signin', message: 'Please login', url: req.originalUrl });
615
+ if (req.query.lang) { q.set('lang', req.query.lang); }
616
+ res.redirect(307, `${loginUrl}?${q}`);
572
617
  } else {
573
618
  next();
574
619
  }
@@ -680,6 +725,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
680
725
  // Summarize rather than dumping the whole config: it is mostly the sensor schema, and it holds
681
726
  // each organization's mqtt_password, which should not be going to the console and the journal.
682
727
  console.log("Broker", config.mqtt.broker, "- organizations:", Object.keys(config.organizations).join(", ") || "(none)");
728
+ console.log(mailInit(config.email));
683
729
  // Could genericize config defaults
684
730
  // HTTP request logging. "morgan: false" in config.d/server.yaml turns it off completely, which
685
731
  // matters on a machine running from an SD card: every request logged is a line to the journal,
@@ -693,6 +739,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
693
739
  morganFormat = ':method :url :req[range] :status :res[content-length] :response-time ms :req[referer]'
694
740
  }
695
741
  // Seems to be writing to syslog which is being cycled.
742
+ morgan.token('url', (req) => redactUrl(req.originalUrl || req.url)); // see redactUrl
696
743
  app.use(morgan(morganFormat)); // see https://www.npmjs.com/package/morgan )
697
744
  }
698
745
 
@@ -712,7 +759,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
712
759
  // Just log the request for now
713
760
  // noinspection JSCheckFunctionSignatures
714
761
  app.use('/', (req, res, next) => {
715
- console.log(req.url);
762
+ console.log(redactUrl(req.url));
716
763
  next();
717
764
  })
718
765
 
@@ -806,6 +853,7 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
806
853
  email: user.email,
807
854
  phone: user.phone,
808
855
  permissions: user.permissions,
856
+ loginAt: Date.now(), // So a permission change can tell which sessions predate it
809
857
  });
810
858
  });
811
859
  });
@@ -822,6 +870,30 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
822
870
  // Check if have a session, and if so store in req.user, uses function defined in deserializeUser above
823
871
  app.use(passport.authenticate('session')); // Add user to req.user
824
872
 
873
+ // End any session that predates a change to that user's permissions.
874
+ //
875
+ // The session carries the permissions read at login, and can_READ, can_ADMIN and the rest
876
+ // read them from there - so without this a change takes effect only when the user next logs
877
+ // in. Granting is merely confusing; revoking is worse, because someone whose ADMIN has been
878
+ // taken away keeps it until their session ends.
879
+ //
880
+ // Rather than re-reading the table on every request to catch something this rare, note when
881
+ // a user's permissions changed and end the sessions older than that. They log in again,
882
+ // which is a small price for a change that happens a handful of times.
883
+ //
884
+ // In memory on purpose: express-session's default store keeps the sessions in this process
885
+ // too, so the two are lost together on a restart and neither can outlive the other.
886
+ app.use((req, res, next) => {
887
+ const changedAt = req.user && permissionsChangedAt.get(Number(req.user.id));
888
+ if (changedAt && (!req.user.loginAt || (changedAt > req.user.loginAt))) {
889
+ console.log("Permissions changed for", req.user.id, "- ending the session made before it");
890
+ // Carry on unauthenticated: a page redirects to login, /config.json answers 401 and the
891
+ // client redirects itself
892
+ return req.logout((err) => (err ? next(err) : next()));
893
+ }
894
+ next();
895
+ });
896
+
825
897
  // ===== API Integration: Farm IoT Interoperability Standard =====
826
898
  // Its tables are in frugal-iot-createdb.sql, already run by execSqlStart above, so there is
827
899
  // nothing to initialize here (lib/database.js still exports initializeSchema for the tests).
@@ -864,11 +936,18 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
864
936
  (req,res,next) => {
865
937
  console.log("Trying to login with redirect to",req.body.url);
866
938
  // This is ugly, but I cannot see how to pass the URL to passport.authenticate options
939
+ // Default the destination rather than trusting every caller to supply one: with no
940
+ // successRedirect, passport calls next() on success and the request 404s as
941
+ // "Cannot POST /login" - a confusing way to report a missing form field.
942
+ const returnTo = req.body.url || '/dashboard';
943
+ const failureQuery = new URLSearchParams({
944
+ mode: 'signin', messagetype: 'error',
945
+ message: 'Incorrect username or password', url: returnTo });
867
946
  passport.authenticate('local', {
868
947
  session: true,
869
948
  //failWithError: true,
870
- failureRedirect: `${loginUrl}?register=false&message=Incorrect+username+or+password&url=${req.body.url}`,
871
- successRedirect: req.body.url,
949
+ failureRedirect: `${loginUrl}?${failureQuery}`,
950
+ successRedirect: returnTo,
872
951
  // In failure case will also be messages in the session which need clearing out TODO-N89
873
952
  //failureRedirect: `${loginUrl}?register=false&message=Incorrect+username+or+password&url=${req.body.url}`,
874
953
  })(req, res, next);
@@ -893,9 +972,11 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
893
972
  [username, hashedPassword, salt, organization, req.body.name, req.body.email, req.body.phone], (err) => {
894
973
  if (err) {
895
974
  console.log(err);
896
- res.redirect(`${loginUrl}?register=true&message=Registration%20failed&url=${req.body.url}`);
975
+ loginRedirect(res, { mode: 'register', messagetype: 'error',
976
+ message: 'Registration failed', url: req.body.url });
897
977
  } else {
898
- res.redirect(`${loginUrl}?register=false&message=Registration%20successful%20-%20please%20login&url=${req.body.url}`);
978
+ loginRedirect(res, { mode: 'signin', messagetype: 'info',
979
+ message: 'Registration successful - please login', url: req.body.url });
899
980
  }
900
981
  });
901
982
  }
@@ -904,6 +985,86 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
904
985
  });
905
986
  });
906
987
 
988
+ // ---------- Forgotten password ----------
989
+ // Nothing is stored: the code is an HMAC over the account and a five-minute slot, so it
990
+ // expires by arithmetic and stops working the moment the password changes. See
991
+ // lib/resetcode.js. The mail carries both a link (a long token) and six digits to type.
992
+ app.post('/forgotpassword', (req, res) => {
993
+ const login = String(req.body.username || '').trim();
994
+ const url = req.body.url;
995
+ // The same answer whether or not the account exists, so this form cannot be used to find
996
+ // out who has an account here.
997
+ const sameAnswer = { mode: 'reset', messagetype: 'info', url,
998
+ message: 'If that account exists we have emailed a reset code' };
999
+ if (!mailConfigured()) {
1000
+ return loginRedirect(res, { mode: 'signin', messagetype: 'error', url,
1001
+ message: 'Password reset is not available on this server' });
1002
+ }
1003
+ if (!resetRateOk('send', login.toLowerCase())) {
1004
+ return loginRedirect(res, { mode: 'forgot', messagetype: 'error', url,
1005
+ message: 'Too many attempts - please wait a few minutes' });
1006
+ }
1007
+ findUserByLogin(login, (err, user) => {
1008
+ if (err) { console.error("Looking up", login, "for a password reset:", err.message); }
1009
+ if (!user || !user.email) { return loginRedirect(res, sameAnswer); }
1010
+ const { code, token } = resetCodeMake(user);
1011
+ const q = new URLSearchParams({ mode: 'reset', username: user.username, code: token });
1012
+ if (url) { q.set('url', url); }
1013
+ const link = `${publicBase(req)}${loginUrl}?${q}`;
1014
+ const text = [
1015
+ `Somebody asked to reset the Frugal IoT password for ${user.username}.`,
1016
+ ``,
1017
+ `Open this link:`,
1018
+ link,
1019
+ ``,
1020
+ `or type this code into the reset form: ${code}`,
1021
+ ``,
1022
+ `Either works for the next ten minutes at most. If it was not you, ignore this mail -`,
1023
+ `nothing has changed and nothing will until the code is used.`,
1024
+ ].join('\n');
1025
+ sendMail({ to: user.email, subject: 'Frugal IoT password reset', text }, (err) => {
1026
+ // Still the same answer: telling them the mail failed would also tell an outsider
1027
+ // that the account exists. It is logged loudly instead, because a server whose SMTP
1028
+ // is broken looks from the outside exactly like one that is working.
1029
+ if (err) { console.error("Could not send reset mail for", user.username, "-", err.message); }
1030
+ loginRedirect(res, sameAnswer);
1031
+ });
1032
+ });
1033
+ });
1034
+ app.post('/resetpassword', (req, res) => {
1035
+ const login = String(req.body.username || '').trim();
1036
+ const url = req.body.url;
1037
+ const notValid = { mode: 'reset', messagetype: 'error', url,
1038
+ message: 'That code is not valid or has expired' };
1039
+ if (!resetRateOk('check', login.toLowerCase())) {
1040
+ return loginRedirect(res, { mode: 'reset', messagetype: 'error', url,
1041
+ message: 'Too many attempts - please wait a few minutes' });
1042
+ }
1043
+ if (!req.body.password) { return loginRedirect(res, notValid); }
1044
+ findUserByLogin(login, (err, user) => {
1045
+ if (err) { console.error("Looking up", login, "to reset a password:", err.message); }
1046
+ if (!user || !resetCodeCheck(user, String(req.body.code || '').trim())) {
1047
+ return loginRedirect(res, notValid);
1048
+ }
1049
+ crypto.randomBytes(16, (err, salt) => {
1050
+ if (err) { return res.status(500).send('Internal error 1101'); }
1051
+ crypto.pbkdf2(req.body.password, salt, 310000, 32, 'sha256', (err, hashedPassword) => {
1052
+ if (err) { return res.status(500).send('Internal error 1103'); }
1053
+ db.run('UPDATE users SET hashed_password = ?, salt = ? WHERE id = ?',
1054
+ [hashedPassword, salt, user.id], (err) => {
1055
+ if (err) {
1056
+ console.error("Could not reset the password for", user.username, "-", err.message);
1057
+ return loginRedirect(res, notValid);
1058
+ }
1059
+ console.log("Password reset for", user.username);
1060
+ loginRedirect(res, { mode: 'signin', messagetype: 'info', url,
1061
+ message: 'Password reset - please sign in' });
1062
+ });
1063
+ });
1064
+ });
1065
+ });
1066
+ });
1067
+
907
1068
  app.get('/config.json',
908
1069
  loggedInOrFail, // Config is only returned if user is logged in.
909
1070
  (req,res) => {
@@ -1021,6 +1182,16 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1021
1182
  },
1022
1183
  send_projects_list,
1023
1184
  );
1185
+ // Log out and go back to the login page. There was no way to do this at all before, which
1186
+ // matters more now that a permissions change ends the session and people log in again.
1187
+ app.get('/logout', (req, res, next) => {
1188
+ // With a url to come back to: without one, POST /login has no successRedirect, so on a
1189
+ // *successful* login passport calls next(), nothing else handles POST /login, and express
1190
+ // answers "Cannot POST /login" - which looks like a broken login rather than a missing
1191
+ // parameter.
1192
+ req.logout((err) => (err ? next(err) : loginRedirect(res, { url: '/dashboard' })));
1193
+ });
1194
+
1024
1195
  // /dashboard is served statically, to logged in users //TODO-N89 restrict orgs to those have permissions for (maybe handled via /config.org )
1025
1196
  const routerDashboard = express.Router();
1026
1197
  app.use('/dashboard', routerDashboard);
@@ -1091,9 +1262,18 @@ mqttLogger.readYamlConfig('.', (err, configobj) => {
1091
1262
  if (isUnsafe([req.body.organization, req.body.project, req.body.deviceid, req.body.otakey])) {
1092
1263
  return cb(new Error("Parameters may not contain '/'"));
1093
1264
  }
1265
+ // An upload that names no organization is a different fault from one the user is not
1266
+ // allowed to make, and without this they are indistinguishable: hasPermissions can
1267
+ // never match an undefined org, so a form that fails to send the field reports itself
1268
+ // as a permission problem and sends whoever is debugging it to the permissions table.
1269
+ // Note multer fills req.body only from the parts that arrive before the file, so a
1270
+ // field the form sends *after* the file is missing here too, even though it was sent.
1271
+ if (!req.body.organization) {
1272
+ return cb(new Error("No organization sent with the upload - the form must send an 'organization' field, ahead of the file"));
1273
+ }
1094
1274
  // Shouldnt actually happen, as dashboard should only show compliant orgs, so this would be a hack (or bad timing)
1095
1275
  if (!hasPermissions(req.user, req.body.organization, "OTAUPDATE")) {
1096
- return cb(new Error("Permission denied to OTAUPDATE"));
1276
+ return cb(new Error(`Permission denied to OTAUPDATE for organization '${req.body.organization}'`));
1097
1277
  }
1098
1278
  let dir;
1099
1279
  if (!req.body.otakey) {
@@ -19,7 +19,12 @@
19
19
  * appending to them.
20
20
  */
21
21
 
22
- import { createReadStream, createWriteStream, readdir, rename, stat, statfs, unlink, readFile, writeFile } from 'fs';
22
+ import { createReadStream, createWriteStream, readdir, rename, stat, unlink, readFile, writeFile } from 'fs';
23
+ // statfs arrived in node 18.15, and this package says it needs node 18. Taken off the namespace
24
+ // rather than imported by name, because a named import of something a builtin does not have fails
25
+ // when the module loads - which would stop the server outright on an 18.x older than that, instead
26
+ // of just doing without the one check that needs it (see freePercent below).
27
+ import fs from 'fs';
23
28
  import { createGzip, gunzip, gzip } from 'zlib';
24
29
  import path from 'path';
25
30
  import { each, eachSeries, waterfall } from 'async';
@@ -147,8 +152,8 @@ function deleteFiles(files, callback) {
147
152
  * How much of the filesystem holding "dir" is free, as a percentage, or null if it cannot be told.
148
153
  */
149
154
  function freePercent(dir, callback) {
150
- if (typeof statfs !== 'function') { callback(null, null); return; } // Older node
151
- statfs(dir, (err, st) => {
155
+ if (typeof fs.statfs !== 'function') { callback(null, null); return; } // node older than 18.15
156
+ fs.statfs(dir, (err, st) => {
152
157
  if (err || !st || !st.blocks) { callback(null, null); return; }
153
158
  callback(null, (st.bavail / st.blocks) * 100);
154
159
  });
@@ -189,7 +194,8 @@ export function housekeep(datadir, config, callback) {
189
194
  // Delete oldest days until there is enough room. The shortfall is worked out in bytes so
190
195
  // that the number of days removed matches what is actually needed.
191
196
  console.log(`Housekeeping: ${free.toFixed(1)}% of the disk free, below the ${freeWanted}% wanted - removing the oldest readings`);
192
- statfs(datadir, (err3, st) => {
197
+ // Reached only when freePercent returned a number, so statfs exists on this node
198
+ fs.statfs(datadir, (err3, st) => {
193
199
  let shortfall = (err3 || !st) ? 0 : ((freeWanted / 100) * st.blocks - st.bavail) * st.bsize;
194
200
  let toRemove = [];
195
201
  let planned = 0;
package/lib/mailer.js ADDED
@@ -0,0 +1,43 @@
1
+ /*
2
+ * Sending mail, configured in config.d/email.yaml.
3
+ *
4
+ * Absent or incomplete configuration means this server does not send mail. That is a supported
5
+ * state - most installations are a Pi in a shed - so mailConfigured() lets a caller say so plainly
6
+ * instead of accepting a request and dropping it.
7
+ */
8
+ import nodemailer from 'nodemailer';
9
+
10
+ let transport = null;
11
+ let mailFrom = null;
12
+
13
+ // emailConfig is config.email, i.e. config.d/email.yaml. Returns a line for the startup log.
14
+ function mailInit(emailConfig) {
15
+ transport = null;
16
+ mailFrom = null;
17
+ if (!emailConfig || !emailConfig.host || !emailConfig.from) {
18
+ return "Not sending mail (no host/from in config.d/email.yaml) - password reset is unavailable";
19
+ }
20
+ const port = emailConfig.port || 587;
21
+ transport = nodemailer.createTransport({
22
+ host: emailConfig.host,
23
+ port,
24
+ // 465 is TLS from the first byte; 587 and 25 start plain and STARTTLS. Wrong either way is a
25
+ // connection that hangs rather than an error that says what is wrong, so derive it.
26
+ secure: (emailConfig.secure !== undefined) ? emailConfig.secure : (port === 465),
27
+ auth: emailConfig.user ? { user: emailConfig.user, pass: emailConfig.pass } : undefined,
28
+ });
29
+ mailFrom = emailConfig.from;
30
+ return `Sending mail via ${emailConfig.host}:${port} as ${mailFrom}`;
31
+ }
32
+
33
+ function mailConfigured() { return !!transport; }
34
+
35
+ // cb(err) - nodemailer is promise-based, wrapped here because the rest of this codebase is callbacks
36
+ function sendMail({ to, subject, text, html }, cb) {
37
+ if (!transport) { return cb(new Error("Mail is not configured on this server")); }
38
+ transport.sendMail({ from: mailFrom, to, subject, text, html })
39
+ .then(() => cb(null))
40
+ .catch((err) => cb(err));
41
+ }
42
+
43
+ export { mailInit, mailConfigured, sendMail };
@@ -0,0 +1,103 @@
1
+ /*
2
+ * Password reset codes, held nowhere.
3
+ *
4
+ * A code is an HMAC over (user id, email, current password hash, five-minute slot), keyed by a
5
+ * secret this process made at startup. Nothing is written to the database and nothing expires by
6
+ * being deleted - a code stops verifying because the slot has moved on, the password has changed,
7
+ * or the server has restarted. Two slots are accepted, so a code lasts between 5 and 10 minutes.
8
+ *
9
+ * Including the current hashed_password is what makes a code single-use: resetting the password
10
+ * changes the hash, and every code derived from the old one stops matching immediately.
11
+ *
12
+ * Two forms come out of the same digest:
13
+ * code - six digits, short enough to read off a phone and type in
14
+ * token - 32 hex characters, put in the emailed link, where length costs nothing
15
+ * Six digits is only a million guesses, so the six-digit form is worth having only alongside the
16
+ * rate limiting below; the link form does not depend on it.
17
+ */
18
+ import crypto from 'crypto';
19
+
20
+ const SLOT_MS = 5 * 60 * 1000; // a code is valid for its own slot and the one before it
21
+ const SLOTS_ACCEPTED = 2;
22
+ // Regenerated every start: a restart invalidates outstanding codes, which is the right way round.
23
+ const secret = crypto.randomBytes(32);
24
+
25
+ // Rate limits, per identifier, in memory. Lost on restart, which also invalidates every code, so
26
+ // there is nothing to carry over. Not a defence against a distributed attacker - it is here so that
27
+ // a million guesses at a six-digit code cannot be made in the ten minutes one is alive.
28
+ const SEND_LIMIT = 3; // reset emails per identifier
29
+ const CHECK_LIMIT = 10; // verification attempts per identifier
30
+ const LIMIT_WINDOW_MS = 15 * 60 * 1000;
31
+ const buckets = { send: new Map(), check: new Map() };
32
+
33
+ function slotNow() { return Math.floor(Date.now() / SLOT_MS); }
34
+
35
+ function digestFor(user, slot) {
36
+ // The password hash is a BLOB from sqlite; a user with no password yet has none.
37
+ const hashed = user.hashed_password ? Buffer.from(user.hashed_password).toString('hex') : '';
38
+ return crypto.createHmac('sha256', secret)
39
+ .update([user.id, user.email || '', hashed, slot].join('\n'))
40
+ .digest();
41
+ }
42
+ function formsFor(user, slot) {
43
+ const d = digestFor(user, slot);
44
+ return {
45
+ code: String(d.readUInt32BE(0) % 1000000).padStart(6, '0'),
46
+ token: d.toString('hex').slice(0, 32),
47
+ };
48
+ }
49
+ // Constant-time, and safe on differing lengths - timingSafeEqual throws on those rather than
50
+ // returning false.
51
+ function sameSecret(a, b) {
52
+ const ba = Buffer.from(String(a));
53
+ const bb = Buffer.from(String(b));
54
+ if (ba.length !== bb.length) { return false; }
55
+ return crypto.timingSafeEqual(ba, bb);
56
+ }
57
+
58
+ // { code, token } for the current slot - what goes in the email
59
+ function resetCodeMake(user) {
60
+ return formsFor(user, slotNow());
61
+ }
62
+ // True if "supplied" is either form, for this slot or the one before
63
+ function resetCodeCheck(user, supplied) {
64
+ if (!supplied) { return false; }
65
+ const now = slotNow();
66
+ let ok = false;
67
+ for (let i = 0; i < SLOTS_ACCEPTED; i++) {
68
+ const { code, token } = formsFor(user, now - i);
69
+ // No early return: keep the work the same whichever slot matches
70
+ ok = sameSecret(supplied, code) || sameSecret(supplied, token) || ok;
71
+ }
72
+ return ok;
73
+ }
74
+
75
+ // True if this identifier may make another attempt of this kind, and counts it if so.
76
+ // bucket is 'send' (asking for an email) or 'check' (trying a code).
77
+ function resetRateOk(bucket, key) {
78
+ const limit = (bucket === 'send') ? SEND_LIMIT : CHECK_LIMIT;
79
+ const map = buckets[bucket];
80
+ const now = Date.now();
81
+ const hits = (map.get(key) || []).filter((t) => (now - t) < LIMIT_WINDOW_MS);
82
+ if (hits.length >= limit) {
83
+ map.set(key, hits);
84
+ return false;
85
+ }
86
+ hits.push(now);
87
+ map.set(key, hits);
88
+ // Nothing else prunes this, and an attacker can name any identifier they like, so drop entries
89
+ // whose window has passed whenever the map gets big rather than growing without bound.
90
+ if (map.size > 1000) {
91
+ for (const [k, v] of map) {
92
+ if (!v.some((t) => (now - t) < LIMIT_WINDOW_MS)) { map.delete(k); }
93
+ }
94
+ }
95
+ return true;
96
+ }
97
+ // So a test can start from a clean slate without waiting out the window
98
+ function resetRateClear() {
99
+ buckets.send.clear();
100
+ buckets.check.clear();
101
+ }
102
+
103
+ export { resetCodeMake, resetCodeCheck, resetRateOk, resetRateClear, SLOT_MS, SLOTS_ACCEPTED };
package/package.json CHANGED
@@ -12,12 +12,14 @@
12
12
  "cookie-parser": "^1.4.7",
13
13
  "express": "^5.2.1",
14
14
  "express-session": "^1.19.0",
15
- "frugal-iot-client": "^1.3.13",
16
- "frugal-iot-logger": "^1.1.18",
15
+ "frugal-iot-client": "^1.3.15",
16
+ "frugal-iot-logger": "^1.1.21",
17
17
  "hash-wasm": "^4.12.0",
18
18
  "js-yaml": "^4.2.0",
19
19
  "morgan": "^1.11.0",
20
+ "mqtt": "^5.15.2",
20
21
  "multer": "^2.1.1",
22
+ "nodemailer": "^9.0.6",
21
23
  "passport": "^0.7.0",
22
24
  "passport-local": "^1.0.0",
23
25
  "sqlite3": "^5.1.7"
@@ -27,7 +29,10 @@
27
29
  "frugal-iot-init": "scripts/init.zsh",
28
30
  "frugal-iot-addorganization": "scripts/addorganization.zsh",
29
31
  "frugal-iot-setpassword": "scripts/setpassword.zsh",
30
- "frugal-iot-diagnostic": "scripts/diagnostic.zsh"
32
+ "frugal-iot-diagnostic": "scripts/diagnostic.zsh",
33
+ "frugal-iot-clearretained": "scripts/clearretained.js",
34
+ "frugal-iot-addbridge-prod": "scripts/addbridge-prod.zsh",
35
+ "frugal-iot-addbridge-pi": "scripts/addbridge-pi.zsh"
31
36
  },
32
37
  "description": "Frugal IoT client and server",
33
38
  "//files": "An allowlist, not an ignore list - anything not named here is never published, so a stray file (a database backup, a credential) cannot leak by default. Check with: npm pack --dry-run",
@@ -35,6 +40,7 @@
35
40
  "frugal-iot-server.js",
36
41
  "frugal-iot-createdb.sql",
37
42
  "config.yaml",
43
+ "config.d/email.yaml",
38
44
  "config.d/logger.yaml",
39
45
  "config.d/mqtt.yaml",
40
46
  "config.d/server.yaml",
@@ -44,6 +50,7 @@
44
50
  "private/",
45
51
  "scripts/",
46
52
  "extras/mosquitto.conf",
53
+ "extras/mosquitto-bridge.conf.example",
47
54
  "extras/frugaliot.service",
48
55
  "API.md",
49
56
  "INSTALLATION.md"
@@ -68,8 +75,9 @@
68
75
  "test:watch": "vitest",
69
76
  "test:coverage": "vitest run --coverage",
70
77
  "check-schema": "node scripts/check-schema.js",
71
- "prerelease": "zsh scripts/prerelease.zsh"
78
+ "prerelease": "zsh scripts/prerelease.zsh",
79
+ "check-files": "node scripts/check-published-files.js"
72
80
  },
73
81
  "type": "module",
74
- "version": "0.3.7"
82
+ "version": "0.3.9"
75
83
  }
package/public/index.html CHANGED
@@ -167,6 +167,14 @@
167
167
  <div class="nav-section">
168
168
  <h2>Other examples</h2>
169
169
  <div class="box-grid">
170
+ <!-- The display the Dashboard used before the cards. Kept reachable while people get used
171
+ to the new one; it will go once nothing needs it. -->
172
+ <div class="nav-box">
173
+ <div class="nav-box-background" style="background-image: url('/images/icon-dashboard.png')"></div>
174
+ <div class="nav-box-content">
175
+ <a href="/dashboard/index-old.html">Previous dashboard</a>
176
+ </div>
177
+ </div>
170
178
  <div class="nav-box">
171
179
  <div class="nav-box-background" style="background-image: url('/images/icon-dashboard.png')"></div>
172
180
  <div class="nav-box-content">
@@ -189,6 +197,14 @@
189
197
  </div>
190
198
  <script>
191
199
  if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
200
+ // The worker calls skipWaiting(), so a new one can take over a page whose scripts came
201
+ // from the old one. Reload once to get a consistent set - but not on a first install,
202
+ // where there was no previous controller and nothing is stale.
203
+ const hadController = !!navigator.serviceWorker.controller;
204
+ let reloading = false;
205
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
206
+ if (hadController && !reloading) { reloading = true; location.reload(); }
207
+ });
192
208
  navigator.serviceWorker.register('/service-worker.js')
193
209
  .then(reg => console.log('Service Worker registered'))
194
210
  .catch(err => console.log('Service Worker registration failed', err));