From 642c2f831afcca2438b00317ff50f6407e6300f1 Mon Sep 17 00:00:00 2001
From: 125m125 <125m125@125m125.de>
Date: Wed, 24 Jul 2019 03:00:59 +0200
Subject: [PATCH 1/5] add basic functions for double opt in mail verification
---
.gitignore | 1 +
modules/db/db_template.sql | 18 +-
modules/notification/index.js | 4 +-
modules/notification/mail/index.js | 89 +++++++-
modules/settings/index.js | 16 +-
modules/translation/lng/en.json | 4 +-
package.json | 8 +-
tests/mochaHooks.js | 1 +
.../notification/mail/mailNotificationTest.js | 193 ++++++++++++++++++
tests/notification/notificationTest.js | 78 +++++++
10 files changed, 396 insertions(+), 16 deletions(-)
create mode 100644 tests/mochaHooks.js
create mode 100644 tests/notification/mail/mailNotificationTest.js
create mode 100644 tests/notification/notificationTest.js
diff --git a/.gitignore b/.gitignore
index a9e5b9e..90dd4f4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ deploy.sh
package-lock.json
launch.json
firebase.json
+pnpm-lock.yaml
diff --git a/modules/db/db_template.sql b/modules/db/db_template.sql
index d0337ae..25acca4 100644
--- a/modules/db/db_template.sql
+++ b/modules/db/db_template.sql
@@ -11,7 +11,6 @@ CREATE TABLE IF NOT EXISTS `settings` (
`user` VARCHAR(6) NOT NULL,
`akey` VARCHAR(6) NOT NULL,
`webhook` VARCHAR(100) DEFAULT NULL,
- `email` VARCHAR(1000) DEFAULT NULL,
`telegram` INT(100) DEFAULT 0,
`abrp` VARCHAR(36) DEFAULT NULL,
`summary` TINYINT(1) DEFAULT 0,
@@ -25,6 +24,23 @@ CREATE TABLE IF NOT EXISTS `settings` (
FOREIGN KEY (`akey`) REFERENCES `accounts`(`akey`)
);
+CREATE TABLE IF NOT EXISTS `notificationMail` (
+ `akey` VARCHAR(6) NOT NULL,
+ `mail` VARCHAR(1000) NOT NULL,
+ `verified` BOOLEAN NOT NULL DEFAULT FALSE,
+ `identifier` BINARY(16) NOT NULL,
+ PRIMARY KEY (`akey`),
+ UNIQUE KEY (`identifier`),
+ FOREIGN KEY (`akey`) REFERENCES `accounts`(`akey`)
+);
+
+CREATE TABLE IF NOT EXISTS `mailLock` (
+ `hash` BINARY(32) NOT NULL,
+ `time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `weight` INTEGER NOT NULL DEFAULT 1,
+ PRIMARY KEY (`hash`)
+);
+
-- sync table structure
CREATE TABLE IF NOT EXISTS `sync` (
`user` VARCHAR(6) NOT NULL,
diff --git a/modules/notification/index.js b/modules/notification/index.js
index 401b54b..4eaf889 100644
--- a/modules/notification/index.js
+++ b/modules/notification/index.js
@@ -26,8 +26,8 @@ const send = (req, res) => {
});
}
// retrieve required information
- db.query('SELECT accounts.akey, token, car, email, telegram, push, lng, soc_display, soc_bms, consumption, last_notification FROM accounts \
- INNER JOIN sync ON accounts.akey=sync.akey INNER JOIN settings ON settings.akey=accounts.akey WHERE accounts.akey=?', [
+ db.query('SELECT accounts.akey, token, car, mail as email, telegram, push, lng, soc_display, soc_bms, consumption, last_notification FROM accounts \
+ INNER JOIN sync ON accounts.akey=sync.akey INNER JOIN settings ON settings.akey=accounts.akey LEFT JOIN notificationMail ON notificationMail.akey=accounts.akey AND verified=TRUE WHERE accounts.akey=?', [
req.body.akey
], (err, dbRes) => {
if (!err && dbRes && (userObj = dbRes[0]) != null) {
diff --git a/modules/notification/mail/index.js b/modules/notification/mail/index.js
index 91d3ade..947b995 100644
--- a/modules/notification/mail/index.js
+++ b/modules/notification/mail/index.js
@@ -4,14 +4,18 @@
* @description Mail notification module
*/
const nodemailer = require('nodemailer'),
+ db = require('./../../db'),
srv_config = require('./../../../srv_config.json'),
srv_errors = require('./../../../srv_errors.json'),
encryption = require('./../../encryption'),
helper = require('./../../helper'),
translation = require('./../../translation');
-const transporter = ((!srv_config.MAIL_SERVICE || !srv_config.MAIL_HOST || !srv_config.MAIL_PORT || !srv_config.MAIL_USER ||
- !srv_config.MAIL_PASSWORD || !srv_config.MAIL_ADDRESS) ? null :
+const doQuery = require('util').promisify(db.query);
+const getRandomBytes = require('util').promisify(require('crypto').randomBytes);
+
+const transporter = (((!srv_config.MAIL_SERVICE && (!srv_config.MAIL_HOST || !srv_config.MAIL_PORT)) || !srv_config.MAIL_USER ||
+ !srv_config.MAIL_PASSWORD || !srv_config.MAIL_ADDRESS) ? null :
nodemailer.createTransport({
host: srv_config.MAIL_HOST,
port: srv_config.MAIL_PORT,
@@ -46,14 +50,14 @@ const sendMail = (userObj, abort) => {
SOC: ((
userObj.soc_display == null) ? SOC_BMS : ((
userObj.soc_bms == null) ?
- SOC_DISPLAY : SOC_DISPLAY))
+ SOC_DISPLAY : SOC_DISPLAY))
}, // use only defined values for text
textObj = {
SOC: ((
userObj.soc_display == null) ? '' + SOC_BMS + ' (BMS)' : ((
userObj.soc_bms == null) ?
- '' + SOC_DISPLAY + ' (Display)' : '' + SOC_DISPLAY + ' (Display) / ' + SOC_BMS + ' (BMS)')),
+ '' + SOC_DISPLAY + ' (Display)' : '' + SOC_DISPLAY + ' (Display) / ' + SOC_BMS + ' (BMS)')),
RANGE: helper.calculateRange(userObj.car, userObj.soc_display || userObj.soc_bms, userObj.consumption) + 'km'
};
@@ -108,9 +112,84 @@ const simpleSend = (mail, subject, html, attachments, callback) => {
});
};
+const setMail = (userObj, mail, callback) => {
+ const akey = userObj.akey;
+ if (!mail) {
+ return doQuery('DELETE FROM notificationMail WHERE akey=?', [akey]).then(() => callback(false)).catch(callback);
+ }
+ if (!validateMail(mail)) {
+ if (typeof callback === 'function') callback(srv_errors.INVALID_PARAMETERS);
+ return;
+ }
+ doQuery('SELECT mail FROM notificationMail WHERE akey=? AND verified=TRUE', [akey])
+ .then(result => {
+ if (result.length > 0 && encryption.decrypt(result[0].mail) === mail) throw new Error('current mail');//TODO
+ })
+ .then(() => module.exports.checkMailUnlocked(mail))
+ .then(() => getRandomBytes(16))
+ .then(id => new Promise((res, rej) => {
+ module.exports.simpleSend(mail, translation.translate('MAIL_SUBJECT_VERIFY', userObj.lng, true),
+ translation.translateWithData('MAIL_TEXT_VERIFY', userObj.lng, { BASE_URL: srv_config.BASE_URL, ID: id.toString('hex') }, true), null, (err, sent) => {
+ if (err) {
+ console.log("mailSendFail")
+ return rej(srv_errors.INVALID_PARAMETERS);
+ }
+ return res(id);
+ });
+ }))
+ .then(id => doQuery('INSERT INTO notificationMail(akey,mail,verified,identifier) VALUES(?,?,false,?) ON DUPLICATE KEY UPDATE mail=VALUES(mail), verified=false, identifier=VALUES(identifier)', [akey, encryption.encrypt(mail), id]))
+ .then(() => module.exports.updateMailLock(mail))
+ .then(() => callback(false)).catch(callback);
+};
+
+const verifyMail = (identifier, callback) => {
+ doQuery('UPDATE notificationMail SET verified=TRUE WHERE identifier=UNHEX(?)', [identifier]).then(queryResult => {
+ if (queryResult.affectedRows !== 1) throw new Error('unknown identifier'); //TODO
+ if (queryResult.changedRows !== 1) throw new Error('already verified'); //TODO
+ }).then(() => callback(false)).catch(callback);
+}
+
+const checkMailUnlocked = mail => {
+ return doQuery('SELECT time FROM mailLock WHERE hash=UNHEX(SHA2(?,256))', [mail]).then(dbRes => {
+ if (dbRes.length === 0) return mail;
+ if (dbRes[0].time.getTime() <= new Date().getTime()) return mail;
+ throw new Error('Recipient is currently locked')
+ });
+}
+
+const updateQuery = `INSERT INTO
+ mailLock
+ SELECT
+ hash,
+ TIMESTAMPADD(MINUTE, POWER(weight + 1, 2), CURRENT_TIMESTAMP()) as time,
+ weight + 1 as weight
+ FROM
+ mailLock
+ WHERE
+ hash = UNHEX(SHA2(?, 256))
+ AND time > TIMESTAMPADD(MINUTE, - POWER(weight + 1, 3), CURRENT_TIMESTAMP())
+ UNION
+ SELECT
+ UNHEX(SHA2(?, 256)),
+ CURRENT_TIMESTAMP() as time,
+ 1 as weight
+ LIMIT
+ 1
+ ON DUPLICATE KEY UPDATE
+ time = VALUES(time),
+ weight = values(weight)
+ `
+const updateMailLock = mail => {
+ return doQuery(updateQuery, [mail, mail]);
+}
+
module.exports = {
validateMail,
sendMail,
sendQRMail,
- simpleSend
+ simpleSend,
+ checkMailUnlocked,
+ updateMailLock,
+ setMail,
+ verifyMail,
};
\ No newline at end of file
diff --git a/modules/settings/index.js b/modules/settings/index.js
index 94eb9ad..b6788d4 100644
--- a/modules/settings/index.js
+++ b/modules/settings/index.js
@@ -7,7 +7,8 @@ const srv_config = require('./../../srv_config.json'),
srv_errors = require('./../../srv_errors.json'),
db = require('./../db'),
token = require('./../token'),
- encryption = require('./../encryption');
+ encryption = require('./../encryption'),
+ mail = require('./../notification/mail');
/**
* Retrieves settings from database for given akey
@@ -15,7 +16,7 @@ const srv_config = require('./../../srv_config.json'),
* @param {Function} callback callback function
*/
const getSettings = (akey, callback) => {
- db.query('SELECT email, telegram, abrp, push, soc, consumption, car, device, lng, summary FROM settings WHERE akey=?', [
+ db.query('SELECT verified as emailVerified, mail as email, telegram, abrp, push, soc, consumption, car, device, lng, summary FROM settings LEFT JOIN notificationMail ON notificationMail.akey=settings.akey WHERE settings.akey=?', [
akey
], (err, dbRes) => {
if (!err && dbRes && dbRes[0] && dbRes[0].email) dbRes[0].email = encryption.decrypt(dbRes[0].email);
@@ -30,8 +31,7 @@ const getSettings = (akey, callback) => {
* @param {Function} callback callback function
*/
const setSettings = (akey, settings, callback) => {
- db.query('UPDATE settings SET email=?, telegram=?, push=?, soc=?, consumption=?, car=?, device=?, lng=?, summary=? WHERE akey=?', [
- ((settings.email) ? encryption.encrypt(settings.email) : ''),
+ db.query('UPDATE settings SET telegram=?, push=?, soc=?, consumption=?, car=?, device=?, lng=?, summary=? WHERE akey=?', [
settings.telegram,
settings.push,
settings.soc,
@@ -41,7 +41,13 @@ const setSettings = (akey, settings, callback) => {
settings.lng,
settings.summary,
akey
- ], (err, dbRes) => callback(err, ((!err && dbRes && dbRes[0]) ? dbRes[0] : null)));
+ ], (err, dbRes) => {
+ if (err) callback(err);
+ mail.setMail({ akey: akey, lng: settings.lng }, settings.email, (err, res) => {
+ if (err && (typeof err !== "error" || err.message !== 'current mail')) return callback(err);
+ callback(false);
+ });
+ });
};
module.exports = {
diff --git a/modules/translation/lng/en.json b/modules/translation/lng/en.json
index 8c35543..48271fc 100644
--- a/modules/translation/lng/en.json
+++ b/modules/translation/lng/en.json
@@ -29,5 +29,7 @@
"TELEGRAM_NOTIFICATION_MESSAGE": "Hello! I just wanted to notify you that your electric vehicle now has reached the desired charging status of {SOC}%! With that, you can drive about {RANGE}.",
"TELEGRAM_NOTIFICATION_ABORT_MESSAGE": "Hello! It seems as if the charging process had been interrupted or there was an error within the communication with the car. Better have a look!",
"MAIL_SUBJECT_QR": "QRNotify: Someone wants to charge",
- "MAIL_TEXT_QR": "Your QR code of QRNotify generated in EVNotify has just been scanned. Someone wants to charge. If possible, you can clear the charging station."
+ "MAIL_TEXT_QR": "Your QR code of QRNotify generated in EVNotify has just been scanned. Someone wants to charge. If possible, you can clear the charging station.",
+ "MAIL_SUBJECT_VERIFY": "Please verify your email address for EVNotify",
+ "MAIL_TEXT_VERIFY": "Hello,
In order to receive notifications from EVNotify, we need you to verify your email address. Klick here or open the following link to complete this process: {BASE_URL}/verify/{ID}"
}
\ No newline at end of file
diff --git a/package.json b/package.json
index dc5e9bb..d5c3c7b 100644
--- a/package.json
+++ b/package.json
@@ -25,10 +25,14 @@
"rollbar": "2.8.1",
"stringinject": "2.1.1"
},
- "devDependencies": {},
+ "devDependencies": {
+ "chai-as-promised": "^7.1.1",
+ "chai-datetime": "^1.5.0",
+ "sinon": "^7.3.2"
+ },
"scripts": {
"pretest": "npm run createEnv",
- "test": "mocha tests/*",
+ "test": "mocha tests --recursive",
"start": "node server.js",
"createEnv": "/bin/bash createEnv.sh"
},
diff --git a/tests/mochaHooks.js b/tests/mochaHooks.js
new file mode 100644
index 0000000..08bf7c4
--- /dev/null
+++ b/tests/mochaHooks.js
@@ -0,0 +1 @@
+after(require('../modules/db').close);
\ No newline at end of file
diff --git a/tests/notification/mail/mailNotificationTest.js b/tests/notification/mail/mailNotificationTest.js
new file mode 100644
index 0000000..fe18112
--- /dev/null
+++ b/tests/notification/mail/mailNotificationTest.js
@@ -0,0 +1,193 @@
+const chai = require('chai');
+const sinon = require('sinon');
+const sandbox = sinon.createSandbox();
+
+const db = require('../../../modules/db');
+const uut = require('../../../modules/notification/mail');
+const encryption = require('./../../../modules/encryption')
+
+const srv_errors = require('./../../../srv_errors.json');
+
+const doQuery = require('util').promisify(db.query);
+
+chai.use(require('chai-as-promised'));
+chai.use(require('chai-datetime'));
+chai.should();
+
+const clearAll = () => {
+ return Promise.all(['sync', 'statistics', 'settings', 'qr', 'notificationMail', 'mailLock', 'logs', 'login', 'devices', 'debug'].map(e => doQuery('DELETE FROM ' + e))).then(() => doQuery('DELETE FROM accounts'));
+}
+
+const realSend = uut.simpleSend;
+
+describe('mail', () => {
+ beforeEach('clear db', clearAll);
+
+ describe('checkMailUnlocked', () => {
+ it('successful when table is empty', () => {
+ return uut.checkMailUnlocked('test@example.com')
+ });
+
+ it('successful on unknown mail', () => {
+ return doQuery('INSERT INTO mailLock(hash,time) VALUES(UNHEX(SHA2("someOtherMail@example.com",256)),TIMESTAMPADD(DAY, 1, CURRENT_TIMESTAMP()))').then(() => uut.checkMailUnlocked('test@example.com'));
+ });
+
+ it('successful on expired lock', () => {
+ return doQuery('INSERT INTO mailLock(hash,time) VALUES(UNHEX(SHA2("test@example.com",256)),TIMESTAMPADD(DAY, -1, CURRENT_TIMESTAMP()))').then(() => uut.checkMailUnlocked('test@example.com'));
+ });
+
+ it('fail on active lock', () => {
+ return doQuery('INSERT INTO mailLock(hash,time) VALUES(UNHEX(SHA2("test@example.com",256)),TIMESTAMPADD(DAY, 1, CURRENT_TIMESTAMP()))').then(() => uut.checkMailUnlocked('test@example.com')).should.eventually.be.rejectedWith(Error, /Recipient is currently locked/);
+ });
+
+ });
+
+ describe('updateMailLock', () => {
+ it('successful when table is empty', () => {
+ return uut.updateMailLock('test@example.com').then(() => doQuery('SELECT HEX(hash),time,weight FROM mailLock ORDER BY hash')).then(queryRes => {
+ const dateRange = createDates(0);
+ queryRes.should.have.lengthOf(1);
+ queryRes[0].should.have.property('weight', 1);
+ queryRes[0].should.have.property('time').withinTime(dateRange[0], dateRange[1])
+ })
+ });
+
+ it('successful on unknown mail', () => {
+ return doQuery('INSERT INTO mailLock(hash,time) VALUES(UNHEX(SHA2("someOtherMail@example.com",256)),TIMESTAMPADD(DAY, 1, CURRENT_TIMESTAMP()))').then(() => uut.updateMailLock('test@example.com')).then(() => doQuery('SELECT HEX(hash) as hash,time,weight FROM mailLock ORDER BY hash')).then(queryRes => {
+ const dateRangeOld = createDates(1440);
+ const dateRangeNew = createDates(0);
+ queryRes.should.have.lengthOf(2);
+
+ queryRes[0].should.have.property('hash', '973DFE463EC85785F5F95AF5BA3906EEDB2D931C24E69824A89EA65DBA4E813B');
+ queryRes[0].should.have.property('weight', 1);
+ queryRes[0].should.have.property('time').withinTime(dateRangeNew[0], dateRangeNew[1])
+
+ queryRes[1].should.have.property('hash', 'A88AC22920CAC00341069D6BD52EAA778187E3AE9109AB2FB1B593024B48F19A');
+ queryRes[1].should.have.property('weight', 1);
+ queryRes[1].should.have.property('time').withinTime(dateRangeOld[0], dateRangeOld[1])
+ })
+ });
+
+ it('successful on known mail', () => {
+ return doQuery('INSERT INTO mailLock(hash,time) VALUES(UNHEX(SHA2("test@example.com",256)), CURRENT_TIMESTAMP())').then(() => uut.updateMailLock('test@example.com')).then(() => doQuery('SELECT HEX(hash) as hash,time,weight FROM mailLock ORDER BY hash')).then(queryRes => {
+ const dateRangeNew = createDates(4);
+ queryRes.should.have.lengthOf(1);
+
+ queryRes[0].should.have.property('hash', '973DFE463EC85785F5F95AF5BA3906EEDB2D931C24E69824A89EA65DBA4E813B');
+ queryRes[0].should.have.property('weight', 2);
+ queryRes[0].should.have.property('time').withinTime(dateRangeNew[0], dateRangeNew[1])
+ })
+ });
+
+ it('successful on known heavy mail', () => {
+ return doQuery('INSERT INTO mailLock(hash,time,weight) VALUES(UNHEX(SHA2("test@example.com",256)), TIMESTAMPADD(DAY, 1, CURRENT_TIMESTAMP()), 19)').then(() => uut.updateMailLock('test@example.com')).then(() => doQuery('SELECT HEX(hash) as hash,time,weight FROM mailLock ORDER BY hash')).then(queryRes => {
+ const dateRangeNew = createDates(400);
+ queryRes.should.have.lengthOf(1);
+
+ queryRes[0].should.have.property('hash', '973DFE463EC85785F5F95AF5BA3906EEDB2D931C24E69824A89EA65DBA4E813B');
+ queryRes[0].should.have.property('weight', 20);
+ queryRes[0].should.have.property('time').withinTime(dateRangeNew[0], dateRangeNew[1])
+ })
+ });
+ });
+
+ describe('set mail', () => {
+ promisedSetMail = require('util').promisify(uut.setMail);
+ beforeEach(() => doQuery('INSERT INTO accounts VALUES(123456,1,2,3),(234567,4,5,6)'));
+ beforeEach(() => sandbox.spy(uut, 'updateMailLock'));
+
+ it('fails if new mail is same as current mail', () => {
+ sandbox.stub(uut, 'simpleSend').callsFake((mail, subject, html, attachments, callback) => callback(false));
+ return doQuery('INSERT INTO notificationMail(akey, mail, verified, identifier) VALUES(123456,?, TRUE, UNHEX("1234567890abcdef1234567890abcdef"))', [encryption.encrypt('test@example.com')])
+ .then(() => promisedSetMail({ akey: '123456', lng: 'en' }, 'test@example.com').should.eventually.be.rejectedWith(Error, /current mail/));
+ });
+
+ it('new mail added successfully', () => {
+ sandbox.stub(uut, 'simpleSend').callsFake((mail, subject, html, attachments, callback) => callback(false));
+ return promisedSetMail({ akey: '123456', lng: 'en' }, 'test@example.com')
+ .then(() => doQuery('SELECT akey,mail,verified FROM notificationMail'))
+ .then(queryRes => {
+ queryRes.should.have.lengthOf(1);
+
+ queryRes[0].should.have.property('akey', '123456');
+ queryRes[0].should.have.property('mail');
+ encryption.decrypt(queryRes[0].mail).should.equal('test@example.com');
+ queryRes[0].should.have.property('verified', 0);
+
+ sinon.assert.calledWith(uut.updateMailLock, 'test@example.com');
+ });
+ });
+
+ it('mail replaced successfully', () => {
+ sandbox.stub(uut, 'simpleSend').callsFake((mail, subject, html, attachments, callback) => callback(false));
+ return doQuery('INSERT INTO notificationMail(akey,mail,verified,identifier) VALUES(?,?,false,?)', [123456, encryption.encrypt('oldtest@example.com'), 'asd'])
+ .then(() => promisedSetMail({ akey: '123456', lng: 'en' }, 'test@example.com'))
+ .then(() => doQuery('SELECT akey,mail,verified FROM notificationMail'))
+ .then(queryRes => {
+ queryRes.should.have.lengthOf(1);
+
+ queryRes[0].should.have.property('akey', '123456');
+ queryRes[0].should.have.property('mail');
+ encryption.decrypt(queryRes[0].mail).should.equal('test@example.com');
+ queryRes[0].should.have.property('verified', 0);
+
+ sinon.assert.calledWith(uut.updateMailLock, 'test@example.com');
+ });
+ });
+
+ it('fails if invalid mail', () => {
+ sandbox.stub(uut, 'simpleSend').callsFake((mail, subject, html, attachments, callback) => callback(false));
+ return promisedSetMail({ akey: '123456', lng: 'en' }, 'test@@@example.com').should.eventually.be.rejected.and.equal(srv_errors.INVALID_PARAMETERS);
+ });
+
+ it('fails if sending of mail failed', () => {
+ sandbox.stub(uut, 'simpleSend').callsFake((mail, subject, html, attachments, callback) => callback(new Error('some error...')));
+ return promisedSetMail({ akey: '123456', lng: 'en' }, 'test@example.com').should.eventually.be.rejected.and.equal(srv_errors.INVALID_PARAMETERS);
+ });
+
+ it('fails if mail currently locked', () => {
+ sandbox.stub(uut, 'simpleSend').callsFake((mail, subject, html, attachments, callback) => callback(false));
+ sandbox.stub(uut, 'checkMailUnlocked').callsFake(() => Promise.reject(new Error('Recipient is currently locked')))
+ return promisedSetMail({ akey: '123456', lng: 'en' }, 'test@example.com').should.eventually.be.rejectedWith(Error, /Recipient is currently locked/)
+ });
+
+ it('removes mail if not specified', () => {
+ return doQuery('INSERT INTO notificationMail(akey,mail,verified,identifier) VALUES(?,?,false,?)', [123456, encryption.encrypt('test@example.com'), 'asd'])
+ .then(() => promisedSetMail({ akey: '123456', lng: 'en' }, null))
+ .then(() => doQuery('SELECT akey,mail,verified FROM notificationMail'))
+ .then(queryRes => {
+ queryRes.should.have.lengthOf(0);
+ });
+ });
+
+ afterEach(() => sandbox.restore())
+ });
+
+ describe('verify mail', () => {
+ beforeEach(() => doQuery('INSERT INTO accounts VALUES(123456,1,2,3),(234567,4,5,6)')
+ .then(() => doQuery('INSERT INTO notificationMail(akey, mail, verified, identifier) VALUES(123456,?, FALSE, UNHEX("1234567890abcdef1234567890abcdef")),(234567,?, TRUE, UNHEX("fedcba0987654321fedcba0987654321"))', ['test@example.com', 'test2@example.com'])));
+ var promisedMailVerification = require('util').promisify(uut.verifyMail);
+
+ it('verification of unverified mail successful', () => {
+ return promisedMailVerification('1234567890abcdef1234567890abcdef');
+ })
+
+ it('verification of verified mail fails', () => {
+ return promisedMailVerification('fedcba0987654321fedcba0987654321').should.eventually.be.rejectedWith(Error, /already verified/);
+ })
+
+ it('verification of unknown mail fails', () => {
+ return promisedMailVerification('eeeeee0987654321fedcba0987654321').should.eventually.be.rejectedWith(Error, /unknown identifier/);
+ })
+ })
+
+ after(() => uut.simpleSend = realSend);
+});
+
+function createDates(minutesToAdd) {
+ const endDateMin = new Date();
+ endDateMin.setMinutes(endDateMin.getMinutes() + minutesToAdd - 1);
+ const endDateMax = new Date();
+ endDateMax.setMinutes(endDateMax.getMinutes() + minutesToAdd + 1);
+ return [endDateMin, endDateMax];
+}
\ No newline at end of file
diff --git a/tests/notification/notificationTest.js b/tests/notification/notificationTest.js
new file mode 100644
index 0000000..54811ac
--- /dev/null
+++ b/tests/notification/notificationTest.js
@@ -0,0 +1,78 @@
+const chai = require('chai');
+const sinon = require('sinon');
+const sandbox = sinon.createSandbox();
+
+const db = require('../../modules/db');
+const uut = require('../../modules/notification');
+const encryption = require('../../modules/encryption')
+const webhook = require('../../modules/webhook');
+const mail = require('../../modules/notification/mail');
+const telegram = require('../../modules/notification/telegram');
+const push = require('../../modules/notification/push')
+
+const doQuery = require('util').promisify(db.query);
+const clearAll = () => {
+ return Promise.all(['sync', 'statistics', 'settings', 'qr', 'notificationMail', 'mailLock', 'logs', 'login', 'devices', 'debug'].map(e => doQuery('DELETE FROM ' + e))).then(() => doQuery('DELETE FROM accounts'));
+}
+var encryptedTestMail = encryption.encrypt('test@example.com')
+
+var reqA = { body: { akey: '123456', token: '3', abort: false } };
+var reqB = { body: { akey: '234567', token: '6', abort: false } };
+var req, res;
+
+describe('test notification sending', () => {
+ beforeEach('clear db', clearAll);
+ beforeEach('create accounts', () => doQuery('INSERT INTO accounts VALUES(123456,1,2,3),(234567,4,5,6)'));
+ beforeEach('create empty syncs', () => doQuery('INSERT INTO sync(user,akey) VALUES(123456,123456),(234567,234567)'));
+ beforeEach('create empty settings', () => doQuery('INSERT INTO settings(user,akey) VALUES(123456,123456),(234567,234567)'));
+ beforeEach('create stubs', () => {
+ sandbox.stub(mail, 'sendMail').returns(null);
+ sandbox.stub(telegram, 'sendMessage');
+ sandbox.stub(push, 'sendPush');
+ sandbox.stub(webhook, 'emit');
+ req = {};
+ res = () => { };
+ })
+
+ describe('mail notifications', () => {
+ it('sends mail if present and verified', done => {
+ req = reqA;
+ res = {
+ json: () => {
+ sinon.assert.calledWith(mail.sendMail, sinon.match.has("email", encryptedTestMail), false);
+ done();
+ }
+ };
+
+ doQuery('INSERT INTO notificationMail(akey, mail, verified, identifier) VALUES(123456,?, TRUE, UNHEX("1234567890abcdef1234567890abcdef"))', [encryptedTestMail])
+ .then(() => uut.send(req, res));
+ });
+
+ it('does not send if not verified', done => {
+ req = reqA;
+ res = {
+ json: () => {
+ sinon.assert.notCalled(mail.sendMail);
+ done();
+ }
+ };
+
+ doQuery('INSERT INTO notificationMail(akey, mail, verified, identifier) VALUES(123456,?, FALSE, UNHEX("1234567890abcdef1234567890abcdef"))', [encryptedTestMail])
+ .then(() => uut.send(req, res));
+ });
+
+ it('does not send if no address', done => {
+ req = reqA;
+ res = {
+ json: () => {
+ sinon.assert.notCalled(mail.sendMail);
+ done();
+ }
+ };
+
+ uut.send(req, res);
+ });
+ });
+
+ afterEach("restore sandbox", () => sandbox.restore());
+});
\ No newline at end of file
From d92161aa67dcecae77457588843c1cdc27329546 Mon Sep 17 00:00:00 2001
From: 125m125 <125m125@125m125.de>
Date: Mon, 29 Jul 2019 23:28:20 +0200
Subject: [PATCH 2/5] add basic verification page for mails
---
index.js | 16 +++++++-----
modules/notification/mail/index.js | 12 +++++----
modules/settings/index.js | 41 ++++++++++++++++++++++++++----
modules/translation/lng/de.json | 6 +++--
modules/translation/lng/en.json | 2 +-
srv_errors.json | 24 +++++++++++++++++
static/verify/de.html | 17 +++++++++++++
static/verify/en.html | 17 +++++++++++++
static/verify/request.js | 21 +++++++++++++++
9 files changed, 136 insertions(+), 20 deletions(-)
create mode 100644 static/verify/de.html
create mode 100644 static/verify/en.html
create mode 100644 static/verify/request.js
diff --git a/index.js b/index.js
index bbb7aad..7084cbf 100644
--- a/index.js
+++ b/index.js
@@ -14,10 +14,10 @@ const express = require('express'),
https = require('https'),
httpsServer = ((!srv_config.DEBUG && srv_config.CHAIN_PATH &&
srv_config.PRIVATE_KEY_PATH && srv_config.CERTIFICATE_PATH) ? https.createServer({
- ca: fs.readFileSync(srv_config.CHAIN_PATH, 'utf-8'),
- key: fs.readFileSync(srv_config.PRIVATE_KEY_PATH, 'utf-8'),
- cert: fs.readFileSync(srv_config.CERTIFICATE_PATH, 'utf-8')
- }, app) : false),
+ ca: fs.readFileSync(srv_config.CHAIN_PATH, 'utf-8'),
+ key: fs.readFileSync(srv_config.PRIVATE_KEY_PATH, 'utf-8'),
+ cert: fs.readFileSync(srv_config.CERTIFICATE_PATH, 'utf-8')
+ }, app) : false),
Rollbar = require('rollbar'),
rollbar = ((srv_config.ROLLBAR_TOKEN) ? new Rollbar({
accessToken: srv_config.ROLLBAR_TOKEN,
@@ -32,7 +32,7 @@ const express = require('express'),
getApiVersion: () => '2',
skip: req => {
const path = req.path.toLowerCase();
-
+
return path === '/location' || path === '/debug' || path === '/soc' || path === '/extended'
}
}) : false),
@@ -105,7 +105,7 @@ app.use((req, res, next) => {
// set default headers
app.use((req, res, next) => {
- res.contentType('application/json');
+ //res.contentType('application/json');
res.setHeader('Access-Control-Allow-Origin', ((!req.get('origin') || req.get('origin') === 'null') ? '*' : req.get('origin')));
res.setHeader('Access-Control-Allow-Credentials', 'true');
next();
@@ -118,6 +118,8 @@ app.post('/login', account.login);
app.post('/changepw', account.changePW);
app.get('/settings', settings.getSettings);
app.put('/settings', settings.setSettings);
+app.use('/verify/:id', express.static('static/verify'))
+app.post('/verify/:id', settings.verifyMail);
app.post('/soc', sync.postSoC);
app.get('/soc', sync.getSoC);
app.post('/extended', sync.postExtended);
@@ -148,7 +150,7 @@ app.post('/debug', (req, res) => {
req.body.data, req.body.akey, ((parseInt(req.body.timestamp)) ? req.body.timestamp : parseInt(new Date() / 1000))
], (err, dbRes) => {
if (!err && dbRes) {
- res.json({status: true});
+ res.json({ status: true });
} else {
res.status(422).json({
error: srv_errors.UNPROCESSABLE_ENTITY,
diff --git a/modules/notification/mail/index.js b/modules/notification/mail/index.js
index 947b995..e25ffdc 100644
--- a/modules/notification/mail/index.js
+++ b/modules/notification/mail/index.js
@@ -123,7 +123,8 @@ const setMail = (userObj, mail, callback) => {
}
doQuery('SELECT mail FROM notificationMail WHERE akey=? AND verified=TRUE', [akey])
.then(result => {
- if (result.length > 0 && encryption.decrypt(result[0].mail) === mail) throw new Error('current mail');//TODO
+ if (result.length > 0 && encryption.decrypt(result[0].mail) === mail) return Promise.reject(srv_errors.CURRENT_MAIL);
+ return true;
})
.then(() => module.exports.checkMailUnlocked(mail))
.then(() => getRandomBytes(16))
@@ -131,8 +132,8 @@ const setMail = (userObj, mail, callback) => {
module.exports.simpleSend(mail, translation.translate('MAIL_SUBJECT_VERIFY', userObj.lng, true),
translation.translateWithData('MAIL_TEXT_VERIFY', userObj.lng, { BASE_URL: srv_config.BASE_URL, ID: id.toString('hex') }, true), null, (err, sent) => {
if (err) {
- console.log("mailSendFail")
- return rej(srv_errors.INVALID_PARAMETERS);
+ if (err.responseCode === 550) return rej(srv_errors.INVALID_MAIL)
+ return rej(err);
}
return res(id);
});
@@ -144,8 +145,9 @@ const setMail = (userObj, mail, callback) => {
const verifyMail = (identifier, callback) => {
doQuery('UPDATE notificationMail SET verified=TRUE WHERE identifier=UNHEX(?)', [identifier]).then(queryResult => {
- if (queryResult.affectedRows !== 1) throw new Error('unknown identifier'); //TODO
- if (queryResult.changedRows !== 1) throw new Error('already verified'); //TODO
+ if (queryResult.affectedRows !== 1) return Promise.reject(srv_errors.NOT_FOUND);
+ if (queryResult.changedRows !== 1) return Promise.reject(srv_errors.CONFLICT);
+ return true;
}).then(() => callback(false)).catch(callback);
}
diff --git a/modules/settings/index.js b/modules/settings/index.js
index b6788d4..e519f4f 100644
--- a/modules/settings/index.js
+++ b/modules/settings/index.js
@@ -44,7 +44,7 @@ const setSettings = (akey, settings, callback) => {
], (err, dbRes) => {
if (err) callback(err);
mail.setMail({ akey: akey, lng: settings.lng }, settings.email, (err, res) => {
- if (err && (typeof err !== "error" || err.message !== 'current mail')) return callback(err);
+ if (err && err.code !== 1102) return callback(err);
callback(false);
});
});
@@ -117,10 +117,19 @@ module.exports = {
settings: req.body.settings
});
} else {
- res.status(422).json({
- error: srv_errors.UNPROCESSABLE_ENTITY,
- debug: ((srv_config.DEBUG) ? err : null)
- });
+ if (err.code && err.message && typeof err.code === "number") {
+ if (!err.status) {
+ err.status = err.code < 600 ? err.code : 400;
+ }
+ var debug = ((srv_config.DEBUG) ? err.debug : null)
+ delete err.debug;
+ return res.status(err.status).json({ error: err, debug });
+ } else {
+ res.status(500).json({
+ error: srv_errors.INTERNAL_SERVER_ERROR,
+ debug: ((srv_config.DEBUG) ? err : null)
+ });
+ }
}
});
} else {
@@ -136,5 +145,27 @@ module.exports = {
});
}
});
+ },
+
+ verifyMail: (req, res) => {
+ if (!req.params.id) {
+ return res.status(400).json({
+ error: srv_errors.INVALID_PARAMETERS
+ });
+ }
+ mail.verifyMail(req.params.id, (err) => {
+ if (err) {
+ if (err.code && err.message) {
+ var debug = ((srv_config.DEBUG) ? err.debug : null)
+ err.debug = null;
+ return res.status(err.code).json({ error: err, debug });
+ }
+ return res.status(500).json({
+ error: srv_errors.INTERNAL_SERVER_ERROR,
+ debug: ((srv_config.DEBUG) ? err : null)
+ });
+ }
+ res.status(204).send();
+ });
}
};
diff --git a/modules/translation/lng/de.json b/modules/translation/lng/de.json
index 746fb5b..9403918 100644
--- a/modules/translation/lng/de.json
+++ b/modules/translation/lng/de.json
@@ -29,5 +29,7 @@
"TELEGRAM_NOTIFICATION_MESSAGE": "Hallo! Ich wollte Dir nur kurz Bescheid geben, dass Dein Elektroauto den gewünschten Ladezustand von {SOC} erreicht hat! Damit wirst Du ungefähr {RANGE} weit fahren können.",
"TELEGRAM_NOTIFICATION_ABORT_MESSAGE": "Hallo! Es scheint so, als sei der Ladevorgang bei {SOC} abgebrochen worden oder es gab einen Fehler bei der Kommunikation mit dem Auto. Am besten mal nachschauen!",
"MAIL_SUBJECT_QR": "QRNotify: Jemand möchte laden",
- "MAIL_TEXT_QR": "Dein QR Code von QRNotify, welcher in EVNotify generiert wurde, wurde soeben eingescannt. Jemand möchte laden. Wenn möglich, kannst Du die Ladesäule frei machen."
-}
+ "MAIL_TEXT_QR": "Dein QR Code von QRNotify, welcher in EVNotify generiert wurde, wurde soeben eingescannt. Jemand möchte laden. Wenn möglich, kannst Du die Ladesäule frei machen.",
+ "MAIL_SUBJECT_VERIFY": "Verifizierung der E-Mail-Adresse für EVNotify",
+ "MAIL_TEXT_VERIFY": "Hallo,
Um Benachrichtigungen von EVNotify zu erhalten, muss zuerst die E-Mail-Adresse verifiziert werden. Klicke hier oder öffne den folgenden Link um diesen Prozess abzuschließen: {BASE_URL}/verify/{ID}/de.html"
+}
\ No newline at end of file
diff --git a/modules/translation/lng/en.json b/modules/translation/lng/en.json
index 48271fc..a042547 100644
--- a/modules/translation/lng/en.json
+++ b/modules/translation/lng/en.json
@@ -31,5 +31,5 @@
"MAIL_SUBJECT_QR": "QRNotify: Someone wants to charge",
"MAIL_TEXT_QR": "Your QR code of QRNotify generated in EVNotify has just been scanned. Someone wants to charge. If possible, you can clear the charging station.",
"MAIL_SUBJECT_VERIFY": "Please verify your email address for EVNotify",
- "MAIL_TEXT_VERIFY": "Hello,
In order to receive notifications from EVNotify, we need you to verify your email address. Klick here or open the following link to complete this process: {BASE_URL}/verify/{ID}"
+ "MAIL_TEXT_VERIFY": "Hello,
In order to receive notifications from EVNotify, we need you to verify your email address. Klick here or open the following link to complete this process: {BASE_URL}/verify/{ID}/en.html"
}
\ No newline at end of file
diff --git a/srv_errors.json b/srv_errors.json
index d52af80..a245c94 100644
--- a/srv_errors.json
+++ b/srv_errors.json
@@ -1,5 +1,6 @@
{
"BAD_REQUEST": {
+ "status": 400,
"code": 400,
"message": "Bad request. Your request could not be processed. This has been automatically reported."
},
@@ -11,6 +12,10 @@
"code": 404,
"message": "Requested source not found."
},
+ "CONFLICT": {
+ "code": 409,
+ "message": "Request conflicts with the current state"
+ },
"UNPROCESSABLE_ENTITY": {
"code": 422,
"message": "Your request could not be processed. This is either a client side error or a server error."
@@ -24,34 +29,52 @@
"message": "Internal server error occured. It has been automatically reported."
},
"UNKNOWN_ROUTE": {
+ "status": 404,
"code": 1000,
"message": "Requested route does not exist. Unable to handle request."
},
"INVALID_PARAMETERS": {
+ "status": 422,
"code": 1100,
"message": "Missing or invalid parameters."
},
+ "INVALID_MAIL": {
+ "status": 422,
+ "code": 1101,
+ "message": "Missing or invalid target mail address."
+ },
+ "CURRENT_MAIL": {
+ "status": 409,
+ "code": 1102,
+ "message": "The target mail address is already the current mail address for this user."
+ },
"DB_QUERY": {
+ "status": 500,
"code": 1200,
"message": "Error on database query."
},
"MALFORMED_AKEY": {
+ "status": 422,
"code": 1300,
"message": "AKey format is not valid. Must be a string with 6 characters."
},
"MALFORMED_PASSWORD": {
+ "status": 422,
"code": 1400,
"message": "Password must be a string with at least 6 characters and less than 72 characters."
},
"ALREADY_REGISTERED": {
+ "status": 409,
"code": 1500,
"message": "Requested AKey already registered."
},
"HASH_FAILED": {
+ "status": 500,
"code": 1600,
"message": "Generation of password hash failed."
},
"USER_NOT_EXISTING": {
+ "status": 404,
"code": 1700,
"message": "Requested user does not exist."
},
@@ -64,6 +87,7 @@
"message": "Provided token is invalid or no longer valid."
},
"MAIL_ALREADY_REGISTERED": {
+ "status": 409,
"code": 2000,
"message": "Requested Mail already registered."
},
diff --git a/static/verify/de.html b/static/verify/de.html
new file mode 100644
index 0000000..1eb0f2d
--- /dev/null
+++ b/static/verify/de.html
@@ -0,0 +1,17 @@
+
+
+