From dae9643cd09f6f79cee4a4be9e79209485672224 Mon Sep 17 00:00:00 2001 From: Thomas Forgione Date: Sat, 23 Sep 2017 16:20:56 +0000 Subject: [PATCH] We have email verification --- controllers/auth/models.js | 12 +- controllers/auth/templates/login-form.html | 24 +++ controllers/auth/templates/login.pug | 15 +- controllers/auth/templates/mail-was-sent.html | 9 + controllers/auth/templates/mailWasSent.pug | 8 + controllers/auth/templates/signup.pug | 38 ++++ .../auth/templates/validation-mail.pug | 50 ++++++ .../auth/templates/validation-mail.txt | 10 ++ controllers/auth/urls.js | 6 + controllers/auth/views.js | 85 +++++++++ index.js | 9 +- package.json | 2 + scripts/clear-db.js | 15 ++ settings/private.js | 8 + templates/404.pug | 6 + templates/base.pug | 4 +- tests/test-password.js | 15 +- utils/controllers.js | 2 +- utils/create-url.js | 7 +- utils/mail.js | 14 ++ utils/model.js | 166 +++++++++++++----- 21 files changed, 446 insertions(+), 59 deletions(-) create mode 100644 controllers/auth/templates/login-form.html create mode 100644 controllers/auth/templates/mail-was-sent.html create mode 100644 controllers/auth/templates/mailWasSent.pug create mode 100644 controllers/auth/templates/signup.pug create mode 100644 controllers/auth/templates/validation-mail.pug create mode 100644 controllers/auth/templates/validation-mail.txt create mode 100644 scripts/clear-db.js create mode 100644 templates/404.pug create mode 100644 utils/mail.js diff --git a/controllers/auth/models.js b/controllers/auth/models.js index 3663271..c95f10b 100644 --- a/controllers/auth/models.js +++ b/controllers/auth/models.js @@ -1,10 +1,14 @@ const model = require('model'); -var user = new model.BaseModel("auth_user"); +var user = new model.BaseModel("authUser"); user.addField(new model.SerialField("id")); -user.addField(new model.TextField("email")); -user.addField(new model.PasswordField("password")); +user.addField(new model.TextField("username", {unique: true, notNull: true})); +user.addField(new model.TextField("email", {unique: true, notNull: true})); +user.addField(new model.BoolField("active", {notNull: true, default: false})); +user.addField(new model.TextField("activationKey")); +user.addField(new model.PasswordField("password", {notNull: true})); user.addField(new model.SmallIntegerField("resources")); -user.addField(new model.SmallIntegerField("project_id")); +user.addField(new model.SmallIntegerField("projectId")); module.exports = model.createClass(user); + diff --git a/controllers/auth/templates/login-form.html b/controllers/auth/templates/login-form.html new file mode 100644 index 0000000..556de28 --- /dev/null +++ b/controllers/auth/templates/login-form.html @@ -0,0 +1,24 @@ +{% extends "pyade/base.html" %} +{% block content %} +{% if login_failed %} +
Username or password incorrect. Please try again.
+{% endif %} +
+
+
+
+
+ +
+
+ +
+
+ +
+ {% csrf_token %} +
+
+
+
+{% endblock %} diff --git a/controllers/auth/templates/login.pug b/controllers/auth/templates/login.pug index ba77221..64cf229 100644 --- a/controllers/auth/templates/login.pug +++ b/controllers/auth/templates/login.pug @@ -1,4 +1,17 @@ extends ../../../templates/base.pug block content - p Login + if loginFailed + .alert + | Username or password incorrect. Please try again. + .row + .col + .col + form(method="POST", action=getUrl("loginTarget")) + .form-group + input.form-control(type="text", name="username", placeholder="Username", autofocus) + .form-group + input.form-control(type="password", name="password", placeholder="Password") + .form-group + input.btn.btn-primary.form-control(type="submit", value="Log in") + .col diff --git a/controllers/auth/templates/mail-was-sent.html b/controllers/auth/templates/mail-was-sent.html new file mode 100644 index 0000000..1fe0934 --- /dev/null +++ b/controllers/auth/templates/mail-was-sent.html @@ -0,0 +1,9 @@ +{% extends "pyade/base.html" %} +{% block content %} +

+ A mail was sent to you. +

+

+ In this mail, you'll find a link to activate your account. +

+{% endblock %} diff --git a/controllers/auth/templates/mailWasSent.pug b/controllers/auth/templates/mailWasSent.pug new file mode 100644 index 0000000..c7cb319 --- /dev/null +++ b/controllers/auth/templates/mailWasSent.pug @@ -0,0 +1,8 @@ +extends ../../../templates/base.pug + +block content + h1 A mail was sent to you + p. + In this mail, you'll find a link to activate your account. + p. + Please, don't forget that my server is cheap, so depending on your mail provider, my email might get thrown in the spam :'( diff --git a/controllers/auth/templates/signup.pug b/controllers/auth/templates/signup.pug new file mode 100644 index 0000000..64adfc1 --- /dev/null +++ b/controllers/auth/templates/signup.pug @@ -0,0 +1,38 @@ +extends ../../../templates/base.pug +block content + if registeringFailed + .alert There was an error. + + .row + .col + .col + form(method="POST", action=getUrl("signupTarget")) + .form-group + input.form-control(type='text', name='username', placeholder='Username', autofocus='') + .form-group + input#email.form-control(type='email', name='email', placeholder='E-mail address') + .form-group + input#pass1.form-control(type='password', name='password', placeholder='Password') + .form-group + input#pass2.form-control(type='password', placeholder='Retype your password') + .form-group + input.btn.btn-primary.form-control(type='submit', value='Log in') + .col + +block extrajs + script. + var mail = document.getElementById('email'), + pass = document.getElementById('pass1'), + pass2 = document.getElementById('pass2'); + + function validatePasswords() { + if (pass1.value === pass2.value) { + pass2.setCustomValidity(''); + } else { + pass2.setCustomValidity("Passwords don't match"); + } + } + + pass1.addEventListener('change', validatePasswords); + pass2.addEventListener('keyup', validatePasswords); + diff --git a/controllers/auth/templates/validation-mail.pug b/controllers/auth/templates/validation-mail.pug new file mode 100644 index 0000000..277cb1e --- /dev/null +++ b/controllers/auth/templates/validation-mail.pug @@ -0,0 +1,50 @@ +style. + .body { + width: 570px; + padding: 0px; + margin-right: auto; + margin-left: auto; + border: solid; + border-width: thin; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + } + + .header { + padding: 10px; + border-bottom: solid; + border-bottom-width: thin; + } + + .footer { + font-size: 75%; + padding: 10px; + border-top: solid; + border-top-width: thin; + } + + h1 { + text-align: center; + } + + p { + padding: 10px; + margin: 0px; + } + + .username { + font-style: italic; + } + +.body + .header + h1 Welcome on ADEjs! + p. + Hello #{user.username}! + p. + You recently subscribed on ADEjs with the email #{user.email}. + p. + To finalize your subscription, please click here to activate your account. + + .footer. + If you did not subscribe on PyADE with this email address, please ignore this e-mail. diff --git a/controllers/auth/templates/validation-mail.txt b/controllers/auth/templates/validation-mail.txt new file mode 100644 index 0000000..88a6d3f --- /dev/null +++ b/controllers/auth/templates/validation-mail.txt @@ -0,0 +1,10 @@ +Hello #{user.username}, and welcome on ADEjs! + +You recently subscribed on ADEjs with the email #{user.email}. + +To finalize your subscription, please click here to activate your account. + +See you later on ADEjs! + +--- +If you did not subscribe on ADEjs with this email address, please ignore this e-mail. diff --git a/controllers/auth/urls.js b/controllers/auth/urls.js index d4a207d..b6bcf9b 100644 --- a/controllers/auth/urls.js +++ b/controllers/auth/urls.js @@ -2,4 +2,10 @@ const url = require('create-url').url; module.exports = [ url('/login', 'login', 'login'), + url('/logout', 'logout', 'logout'), + url('/login-target', 'loginTarget', 'loginTarget', 'POST'), + url('/signup', 'signup', 'signup'), + url('/signup-target', 'signupTarget', 'signupTarget', 'POST'), + url('/mail-was-sent', 'mailWasSent', 'mailWasSent'), + url('/activate/:activationKey', 'activate', 'activate'), ] diff --git a/controllers/auth/views.js b/controllers/auth/views.js index 78d9746..019dfd0 100644 --- a/controllers/auth/views.js +++ b/controllers/auth/views.js @@ -1,10 +1,95 @@ +const testPassword = require('model').PasswordField.testSync; const User = require('./models.js'); const getUrl = require('create-url').getUrl; +const mail = require('mail'); +const pug = require('pug'); +const config = require('settings/config'); module.exports.login = function(req, res, render) { render('login.pug'); } +module.exports.signup = function(req, res, render) { + render('signup.pug'); +} + module.exports.logout = function(req, res, render) { + req.session.user = undefined; + req.session.save(); res.redirect(getUrl("index")); } + +module.exports.loginTarget = function(req, res, render) { + User.getByUsername(req.body.username, (err, user) => { + if (user === undefined || !user.active) { + res.redirect(getUrl('login')); + } else { + if (testPassword(req.body.password, user.password)) { + req.session.user = user; + req.session.save(); + res.redirect(getUrl('index')); + } else { + res.redirect(getUrl('login')); + } + } + }); +} + +module.exports.signupTarget = function(req, res, render) { + let user = new User(); + user.username = req.body.username; + user.email = req.body.email; + user.password = req.body.password; + + require('crypto').randomBytes(48, function(err, buffer) { + user.activationKey = buffer.toString('hex'); + user.save((err) => { + res.locals.user = user; + + let baseUrl = + req.headers.referer.split('/').slice(0, 3).join('/') + '/' + + res.locals.activationUrl = + baseUrl + 'activate/' + user.activationKey; + + let html = pug.renderFile( + __dirname + '/templates/validation-mail.pug', + res.locals, + ); + + mail({ + from: config.MAIL.FROM, + to: user.username + ' <' + user.email + '>', + subject: 'Welcome on ADEjs!', + text:'', + attachment: [ + {data: html, alternative: true} + ] + }, (err, result) => { + res.redirect(getUrl('mailWasSent')); + }); + + }); + }); +} + +module.exports.mailWasSent = function(req, res, render) { + render('mailWasSent.pug'); +} + +module.exports.activate = function(req, res, render, next) { + User.getByActivationKey(req.params.activationKey, (err, user) => { + if (user === undefined) { + return next(); + } + + user.active = true; + user.activationKey = undefined; + user.save((err, user) => { + req.session.user = user; + req.session.save(); + res.redirect(getUrl('index')); + }); + }); +} + diff --git a/index.js b/index.js index 1eebfc8..efcc46f 100644 --- a/index.js +++ b/index.js @@ -3,7 +3,6 @@ require('app-module-path').addPath(config.BASE_DIR); require('app-module-path').addPath(config.UTILS_DIR); require('app-module-path').addPath(config.CONTROLLERS_DIR); -const repl = require('repl'); const express = require('express'); const pug = require('pug'); const http = require('http'); @@ -19,7 +18,10 @@ function startServer() { let app = express(); let http = require('http').Server(app); - let bodyParser = require('body-parser'); + var bodyParser = require('body-parser'); + app.use(bodyParser.json()); + app.use(bodyParser.urlencoded({ extended: true })); + let session = require('cookie-session'); let cookieParser = require('cookie-parser'); @@ -52,7 +54,7 @@ function startServer() { app.use(function(req, res) { res.setHeader('Content-Type', 'text/html'); - res.render('404.pug', res.locals, function(err, result) { + res.render(config.BASE_DIR + '/templates/404.pug', res.locals, function(err, result) { if (err) console.log(err); res.send(result); @@ -77,7 +79,6 @@ function startServer() { const commands = { runserver: startServer, reinitdb: model.reinitialize, - shell: repl.start, } function showHelp() { diff --git a/package.json b/package.json index ce5870d..acdd8ce 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,12 @@ "author": "Thomas Forgione", "license": "MIT", "dependencies": { + "app-module-path": "^2.2.0", "bcryptjs": "^2.4.3", "body-parser": "^1.18.2", "cookie-parser": "^1.4.3", "cookie-session": "^1.3.1", + "emailjs": "^1.0.12", "express": "^4.15.4", "pg": "^7.3.0", "pug": "^2.0.0-rc.4", diff --git a/scripts/clear-db.js b/scripts/clear-db.js new file mode 100644 index 0000000..6d4f3e1 --- /dev/null +++ b/scripts/clear-db.js @@ -0,0 +1,15 @@ +const config = require('../settings/config.js'); +require('app-module-path').addPath(config.BASE_DIR); +require('app-module-path').addPath(config.UTILS_DIR); +require('app-module-path').addPath(config.CONTROLLERS_DIR); + +const model = require('model'); +const User = require('auth/models'); +const log = require('log'); + +model.reinitialize(() => { + + log.debug('Database reinitialized'); + process.exit(0); + +}); diff --git a/settings/private.js b/settings/private.js index 599aedd..5ea3064 100644 --- a/settings/private.js +++ b/settings/private.js @@ -6,5 +6,13 @@ module.exports = { 'USERNAME': 'adejs', 'DBNAME': 'adejs', 'PASSWORD':'97Cnqw023V1G95fQUJR8H7gwvqUke4', + }, + 'MAIL': { + 'USERNAME': 'adejs', + 'PASSWORD': 'ILm0iSIgYI18w41dJU3ll9Pe5w9qjJ', + 'HOSTNAME': 'smtp.tforgione.fr', + 'PORT': '587', + 'FROM': 'ADEjs ', + 'TLS': true, } }; diff --git a/templates/404.pug b/templates/404.pug new file mode 100644 index 0000000..058382c --- /dev/null +++ b/templates/404.pug @@ -0,0 +1,6 @@ +extends ./base.pug + +block content + h1 Error 404 + p. + This page does not exist. diff --git a/templates/base.pug b/templates/base.pug index 3b7781c..0e4650e 100644 --- a/templates/base.pug +++ b/templates/base.pug @@ -30,12 +30,12 @@ html a.btn.btn-light(href=getUrl("login"), role='button') | Sign in li.nav-item - a.btn.btn-success(href='#', role='button') + a.btn.btn-success(href=getUrl("signup"), role='button') | Sign up else li.nav-item a.nav-link(href='#') - | Username + | #{session.user._username.value} li.nav-item a.btn.btn-light(href=getUrl("logout"), role='button') | Log out diff --git a/tests/test-password.js b/tests/test-password.js index d768a9f..12c64c8 100644 --- a/tests/test-password.js +++ b/tests/test-password.js @@ -12,16 +12,29 @@ model.reinitialize(() => { log.debug('Database reinitialized'); let user = new User(); - user.email = "toto"; + user.username = "toto" + user.email = "toto@tforgione.fr"; user.password = "tata"; user.resources = 23; user.project_id = 42; user.save((err, u) => { + if (err) { + log.dberror(err); + return; + } + log.debug('New user created'); // Test password User.getById(1, (err, user) => { + + if (err !== undefined) { + log.dberror("User is undefined"); + console.log(err); + return; + } + if (model.PasswordField.testSync("tata", user.password)) { log.debug("Password authentication succeed"); } else { diff --git a/utils/controllers.js b/utils/controllers.js index 35eb9aa..0720c7e 100644 --- a/utils/controllers.js +++ b/utils/controllers.js @@ -22,7 +22,7 @@ module.exports = function(app, controllersDir = __dirname + '/../controllers') { for (let url of urls) { - app.get(url.url, ((url) => function(req, res, next) { + app[url.method.toLowerCase()](url.url, ((url) => function(req, res, next) { let path = obj[url.view](req, res, function(template) { diff --git a/utils/create-url.js b/utils/create-url.js index dcffe49..08dc75f 100644 --- a/utils/create-url.js +++ b/utils/create-url.js @@ -1,10 +1,11 @@ let urls = {}; class Url { - constructor(url, view, name) { + constructor(url, view, name, method="GET") { this.url = url; this.view = view; this.name = name; + this.method = method; } } @@ -19,8 +20,8 @@ module.exports = function(req, res, next) { module.exports.getUrl = getUrl; -module.exports.url = function(url, viewName, urlName) { - let ret = new Url(url, viewName, urlName); +module.exports.url = function(url, viewName, urlName, method) { + let ret = new Url(url, viewName, urlName, method); urls[urlName] = ret; return ret; } diff --git a/utils/mail.js b/utils/mail.js new file mode 100644 index 0000000..5841e44 --- /dev/null +++ b/utils/mail.js @@ -0,0 +1,14 @@ +const config = require('settings/config'); +const mail = require('emailjs'); + +module.exports = function(params, callback) { + let server = mail.server.connect({ + user: config.MAIL.USERNAME, + password: config.MAIL.PASSWORD, + host: config.MAIL.HOSTNAME, + port: config.MAIL.PORT, + tls: config.MAIL.TLS + }); + + server.send(params, callback); +}; diff --git a/utils/model.js b/utils/model.js index 4a4df11..a0edf64 100644 --- a/utils/model.js +++ b/utils/model.js @@ -5,6 +5,40 @@ const bc = require('bcryptjs'); let models = []; +function postgresToCamel(postgres) { + let ret = ''; + for (let i = 0; i < postgres.length; i++) { + let c = postgres.charAt(i); + if (c === '_') { + i++; + ret += postgres.charAt(i).toUpperCase(); + } else { + ret += c; + } + } + + return ret; +} + +function camelToPostgres(camelCase) { + let ret = ''; + for (let i = 0; i < camelCase.length; i++) { + let c = camelCase.charAt(i); + if (c === c.toUpperCase()) { + ret += '_' + c.toLowerCase() + } else { + ret += c; + } + } + + return ret; +} + +function attributeToGetBy(attributeName) { + return 'getBy' + + attributeName.charAt(0).toUpperCase() + attributeName.substr(1); +} + module.exports.BaseModel = class { constructor(name) { models.push(this); @@ -19,13 +53,17 @@ module.exports.BaseModel = class { reinitialize(callback = () => {}) { log.debug('Reinitializing ' + this.name); - db.query('DROP TABLE IF EXISTS ' + this.name + ' CASCADE') + let query = + "DROP TABLE IF EXISTS " + + camelToPostgres(this.name) + " CASCADE;"; + + db.query(query) .then(res => this.createTable(callback)) .catch(err => log.dberror(err)); } createTable(callback) { - let query = 'CREATE TABLE ' + this.name + '(\n'; + let query = 'CREATE TABLE ' + camelToPostgres(this.name) + '(\n'; query += this.fields .map(f => '\t' + f.getCreationString()) .join(',\n'); @@ -35,7 +73,7 @@ module.exports.BaseModel = class { db.query(query) .then(res => { - log.debug("Table " + this.name + " created") + log.debug("Table " + camelToPostgres(this.name) + " created") callback(); }) .catch(err => log.dberror(err)); @@ -43,8 +81,30 @@ module.exports.BaseModel = class { } module.exports.BaseField = class { - constructor(name) { + constructor(name, properties = {}) { this.name = name; + this.unique = properties.unique || false; + this.notNull = properties.notNull || false; + this.default = properties.default; + } + + getCreationString() { + return [ + camelToPostgres(this.name), + this.getPostgresType(), + this.getFormattedPropeties(), + ].join(' '); + } + + getFormattedPropeties() { + let properties = []; + if (this.unique) { + properties.push('UNIQUE'); + } + if (this.notNull) { + properties.push('NOT NULL'); + } + return properties.join(' '); } createMutators() { @@ -73,43 +133,43 @@ module.exports.BaseField = class { } module.exports.SmallIntegerField = class extends module.exports.BaseField { - constructor(name) { - super(name); + constructor(name, properties = {}) { + super(name, properties); } - getCreationString() { - return this.name + " SMALLINT"; + getPostgresType() { + return "SMALLINT"; } } module.exports.SerialField = class extends module.exports.BaseField { - constructor(name) { - super(name); + constructor(name, properties = {}) { + super(name, properties); + this.unique = true; } - getCreationString() { - return this.name + " SERIAL"; + getPostgresType() { + return "SERIAL"; } - } module.exports.TextField = class extends module.exports.BaseField { - constructor(name) { - super(name); + constructor(name, properties = {}) { + super(name, properties); } - getCreationString() { - return this.name + " TEXT"; + getPostgresType() { + return "TEXT"; } } module.exports.PasswordField = class extends module.exports.BaseField { - constructor(name) { - super(name); + constructor(name, properties = {}) { + super(name, properties); } - getCreationString() { - return this.name + ' TEXT'; + getPostgresType() { + return 'TEXT'; } @@ -121,7 +181,6 @@ module.exports.PasswordField = class extends module.exports.BaseField { changed: true, }; } - } } @@ -130,36 +189,57 @@ module.exports.PasswordField.testSync = function(password, hash) { } +module.exports.BoolField = class extends module.exports.BaseField { + constructor(name, properties) { + super(name, properties); + } + + getPostgresType() { + return 'BOOLEAN'; + } +} + module.exports.createClass = function(model) { let ret = function(param) { this._persisted = false; for (let field of model.fields) { this['_' + field.name] = { - value: undefined, - changed: false, + value: field.default, + changed: true, } Object.defineProperty(this, field.name, field.createMutators()); } }; - ret.getById = function(id, callback) { - db - .query('SELECT * FROM ' + model.name + ' WHERE id=$1;', [id]) - .then(res => { - // Create the instance - let instance = new ret(); - instance._persisted = true; - instance._setFromDbResult(res); - callback(undefined, instance); - }) - .catch(err => callback(err)); + for (let field of model.fields) { + if (!field.unique && field.name !== 'activationKey') + continue; + + ret[attributeToGetBy(field.name)] = function(value, callback) { + let query = + 'SELECT * FROM ' + camelToPostgres(model.name) + + ' WHERE ' + camelToPostgres(field.name) + + '=$1;'; + + db + .query(query, [value]) + .then(res => { + // Create the instance + let instance = new ret(); + instance._persisted = true; + instance._setFromDbResult(res); + callback(undefined, instance); + }) + .catch(err => callback(err)); + + } } ret.prototype._setFromDbResult = function(res) { for (let field of model.fields) { this['_' + field.name] = { - value: res.rows[0][field.name], + value: res.rows[0][camelToPostgres(field.name)], changed: false, }; } @@ -182,10 +262,10 @@ module.exports.createClass = function(model) { if (!this._persisted) { // INSERT INTO - let queryBegin = 'INSERT INTO ' + model.name + '(' + let queryBegin = 'INSERT INTO ' + camelToPostgres(model.name) + '(' let queryEnd = ' VALUES ('; queryBegin += fieldsToSave - .map(f => f.field.name) + .map(f => camelToPostgres(f.field.name)) .join(','); queryEnd += fieldsToSave @@ -208,16 +288,16 @@ module.exports.createClass = function(model) { } else { // UPDATE FROM - let query = 'UPDATE FROM ' + model.name + ' SET '; + let query = 'UPDATE ' + camelToPostgres(model.name) + ' SET '; - query += fieldsToSave.map(f => { - return f.field.name + '=' + f.value; + query += fieldsToSave.map((f, i) => { + return camelToPostgres(f.field.name) + '=$' + (i+1); }).join(','); - query += ') RETURNING *;' + query += ' WHERE id=$' + (fieldsToSave.length + 1) + ' RETURNING *;' db - .query(query, fieldsToSave.map(f => f.value)) + .query(query, [...fieldsToSave.map(f => f.value), this.id]) .then(res => { this._setFromDbResult(res); this._persisted = true;