diff --git a/README.md b/README.md index 1d3557f..000957a 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,8 @@ Update the line endings of any files that are crlf to lf and try again. In order for file changes to be picked up by the watchers in client side code, be sure to set `CHOKIDAR_USEPOLLING=true` in the `.env` file. +For the server, update the npm start command in package.json to use legacy polling: `"start": "nodemon -L",` + ### Running without docker You should be able to run the site locally without docker if desired. Make diff --git a/client b/client index 84c833c..d7d2b49 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 84c833c3586a64db1665f0efbec5f7cc2b8c62c0 +Subproject commit d7d2b491d77da73eed629909d0bf15582d43445c diff --git a/server/package-lock.json b/server/package-lock.json index ad63be7..6e1599c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -117,6 +117,15 @@ "@types/serve-static": "*" } }, + "@types/express-brute": { + "version": "0.0.37", + "resolved": "https://registry.npmjs.org/@types/express-brute/-/express-brute-0.0.37.tgz", + "integrity": "sha512-N1eDsPeRadiaLLfcCOxtQWSNN7ipdrg7IvRZNzQaUTvVvfGYwu8eZHOBbRrYb632jooc2Mmzz+TovBm+44itFA==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/express-serve-static-core": { "version": "4.16.1", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.16.1.tgz", @@ -1317,6 +1326,60 @@ } } }, + "brute-knex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/brute-knex/-/brute-knex-3.0.1.tgz", + "integrity": "sha512-245ygt7gNryQ/r/S/eLQiYqXAYyhFTeqHdzOpZGZakpgRhDg83/kNrTulxkSjCa9HPKT4ILoi5j0GiUrXDziDw==", + "requires": { + "express-brute": "^1.0.0", + "knex": "0.14.6" + }, + "dependencies": { + "chalk": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.2.tgz", + "integrity": "sha512-ZM4j2/ld/YZDc3Ma8PgN7gyAk+kHMMMyzLNryCPGhWrsfAuDVeuid5bpRFTDgMH9JBK2lA4dyyAkkZYF/WcqDQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "knex": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/knex/-/knex-0.14.6.tgz", + "integrity": "sha512-A+iP8oSSmEF3JbSMfUGuJveqduDMEgyS5E/dO0ycVzAT4EE5askfunk7+37+hPqC951vnbFK/fIiNDaJIjVW0w==", + "requires": { + "babel-runtime": "^6.26.0", + "bluebird": "^3.5.1", + "chalk": "2.3.2", + "commander": "^2.15.1", + "debug": "3.1.0", + "inherits": "~2.0.3", + "interpret": "^1.1.0", + "liftoff": "2.5.0", + "lodash": "^4.17.5", + "minimist": "1.2.0", + "mkdirp": "^0.5.1", + "pg-connection-string": "2.0.0", + "readable-stream": "2.3.6", + "safe-buffer": "^5.1.1", + "tarn": "^1.1.4", + "tildify": "1.2.0", + "uuid": "^3.2.1", + "v8flags": "^3.0.2" + } + } + } + }, "bser": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/bser/-/bser-2.0.0.tgz", @@ -1651,8 +1714,7 @@ "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "dev": true + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, "cpx": { "version": "1.5.0", @@ -2415,6 +2477,15 @@ } } }, + "express-brute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/express-brute/-/express-brute-1.0.1.tgz", + "integrity": "sha1-nzbRB/405ApoJZPjm//MUxArUzU=", + "requires": { + "long-timeout": "~0.1.1", + "underscore": "~1.8.3" + } + }, "express-session": { "version": "1.15.6", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.15.6.tgz", @@ -5254,6 +5325,11 @@ "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", "dev": true }, + "long-timeout": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", + "integrity": "sha1-lyHXiLR+C8taJMLivuGg2lXatRQ=" + }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -6308,8 +6384,7 @@ "process-nextick-args": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", - "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", - "dev": true + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" }, "prompts": { "version": "0.1.14", @@ -6466,7 +6541,6 @@ "version": "2.3.6", "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", - "dev": true, "requires": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -7804,6 +7878,11 @@ "debug": "^2.2.0" } }, + "underscore": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz", + "integrity": "sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI=" + }, "union-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", diff --git a/server/package.json b/server/package.json index 7cc656f..cc9fc70 100644 --- a/server/package.json +++ b/server/package.json @@ -32,10 +32,11 @@ "@types/body-parser": "1.16.7", "@types/bookshelf": "0.9.9", "@types/express": "4.16.0", + "@types/express-brute": "0.0.37", "@types/jest": "23.3.9", "@types/knex": "0.15.1", - "@types/node": "8.0.47", "@types/mustache": "0.8.32", + "@types/node": "8.0.47", "@types/passport": "0.4.6", "@types/validator": "9.4.1", "jest": "23.6.0", @@ -51,9 +52,11 @@ "bcrypt": "3.0.2", "body-parser": "1.18.3", "bookshelf": "0.13.3", + "brute-knex": "3.0.1", "connect-session-knex": "1.4.0", "dotenv": "6.0.0", "express": "4.16.3", + "express-brute": "1.0.1", "express-session": "1.15.6", "knex": "0.15.2", "moment": "2.22.2", diff --git a/server/src/api.ts b/server/src/api.ts index 0d8ae8b..75ec718 100644 --- a/server/src/api.ts +++ b/server/src/api.ts @@ -7,7 +7,7 @@ import todosRouter from "./components/todos/todosRouter"; const router = express.Router(); router.get("/", function(req: express.Request, res: express.Response, next: express.NextFunction) { - res.json({ hello: "world!"}); + res.json({ hello: "world?"}); }); router.use("/auth/", authRouter); diff --git a/server/src/components/auth/authRouter.ts b/server/src/components/auth/authRouter.ts index 5b08965..71a5799 100644 --- a/server/src/components/auth/authRouter.ts +++ b/server/src/components/auth/authRouter.ts @@ -22,6 +22,9 @@ router.post("/", (req: any, res: express.Response, next: express.NextFunction) = Passport.authenticate("login", (err, user) => { if (err) { return next(err); } if (!user) { return next(new Error("login failed")); } + if (user.get("activated") === false) { + return next(new Error("user not activated")); + } req.logIn(user, (loginErr) => { if (loginErr) { return next(loginErr); diff --git a/server/src/components/auth/index.ts b/server/src/components/auth/index.ts index eb9e35b..64321b3 100644 --- a/server/src/components/auth/index.ts +++ b/server/src/components/auth/index.ts @@ -79,7 +79,7 @@ export default class AuthController { }); Passport.deserializeUser((id: any, done) => { - console.log("poo", id); + console.log("deserializeUser", id); usersController.getUser(id) .then((user) => { done(null, user || {}); diff --git a/server/src/components/base/baseModel.ts b/server/src/components/base/baseModel.ts index 6f96ffc..897565f 100644 --- a/server/src/components/base/baseModel.ts +++ b/server/src/components/base/baseModel.ts @@ -1,5 +1,5 @@ import Database from "../../database"; -export default class BaseModel extends Database.bookshelf.Model { +export default class BaseModel extends Database.bookshelf.Model { } diff --git a/server/src/components/reset-password/passwordResetModel.ts b/server/src/components/reset-password/passwordResetModel.ts index 6b2b79a..a09494d 100644 --- a/server/src/components/reset-password/passwordResetModel.ts +++ b/server/src/components/reset-password/passwordResetModel.ts @@ -5,7 +5,7 @@ export default class PasswordResetModel extends BaseModel { get tableName() { return "passwordreset"; } get idAttribute() { return "tokenhash"; } get hasTimestamps() { return true; } - get user() { + public user() { return this.hasOne(UserModel); } } diff --git a/server/src/components/todos/todoModel.ts b/server/src/components/todos/todoModel.ts index 2f8f319..b70b30d 100644 --- a/server/src/components/todos/todoModel.ts +++ b/server/src/components/todos/todoModel.ts @@ -5,7 +5,7 @@ export default class TodoModel extends BaseModel { get tableName() { return "todos"; } get idAttribute() { return "id"; } get hasTimestamps() { return true; } - get user() { + public user() { return this.hasOne(UserModel); } } diff --git a/server/src/components/users/index.ts b/server/src/components/users/index.ts index 901b22a..35ea6d5 100644 --- a/server/src/components/users/index.ts +++ b/server/src/components/users/index.ts @@ -4,6 +4,16 @@ import handleDatabaseErrors from "../../util/handleDatabaseErrors"; import UserModel from "./userModel"; import BaseModel from "../base/baseModel"; import {Collection, Model} from "bookshelf"; +import sendEmail from "../../util/sendEmail"; +import Mustache = require("mustache"); +import * as fs from "fs"; +import {promisify} from "util"; +import UserActivationModel from "./userActivationModel"; +import PasswordResetModel from "../reset-password/passwordResetModel"; +import * as moment from "moment"; + +// Convert the filesystem stuff to be promises +const readFile = promisify(fs.readFile); export default class UsersController { @@ -14,7 +24,7 @@ export default class UsersController { return usersCollection; } - public async getUser(id: string): Promise { + public async getUser(id: string): Promise { if (!Validator.isUUID(id, "4")) { throw new Error("Invalid ID"); } @@ -25,7 +35,7 @@ export default class UsersController { return user; } - public async getUserByEmail(email: string): Promise { + public async getUserByEmail(email: string): Promise { if (!Validator.isEmail(email)) { throw new Error("Invalid Email"); } @@ -36,6 +46,14 @@ export default class UsersController { return user; } + public async getUserActivationByToken(token: string): Promise { + const userActivation = await new UserActivationModel() + .where({token}) + .fetch({withRelated: ["user"]}) + .catch(handleDatabaseErrors); + return userActivation; + } + public async createUser(data: any): Promise { // const requiredFields = ["password", "name", "email"]; // const missingRequired = validation.checkRequiredFields(requiredFields, data); @@ -66,6 +84,100 @@ export default class UsersController { .save() .catch(handleDatabaseErrors); + const userActivation = await this.createUserActivation(user.id); + + // send activation email + await this.sendActivationEmail(userActivation); + + return user; + } + + public async createUserActivation(userId: number): Promise { + const userActivation = await new UserActivationModel({user_id: userId}) + .save() + .catch(handleDatabaseErrors); + return userActivation; + } + + public async resendActivationEmail(email: string): Promise { + const user = await this.getUserByEmail(email); + if (!user) { + throw new Error("User does not exist"); + } + if (user.get("activated") === true) { + throw new Error("User already activated"); + } + + // delete any existing activation tokens + const oldUserActivation = await new UserActivationModel() + .where({user_id : user.id}) + .destroy({require: false}) + .catch(handleDatabaseErrors); + + const userActivation = await this.createUserActivation(user.id); + await this.sendActivationEmail(userActivation); + return user; } + + public async activateUser(token: string): Promise { + const userActivation = await this.getUserActivationByToken(token); + if (!userActivation) { + throw new Error("Token is not valid"); + } + + const user = userActivation.related("user") as UserModel; + if (!user) { + throw new Error("Token is not valid"); + } + + if (user.get("activated") === true) { + throw new Error("User already activated"); + } + + const isExpired = moment(userActivation.get("created_at")).isBefore(moment().subtract(24, "hours")); + if (isExpired) { + throw new Error("Activation token has expired"); + } + user.set({activated: true}).save(); + + userActivation + .destroy() + .catch(handleDatabaseErrors); + + return { + isValid: true, + user, + userActivation, + }; + } + + public async sendActivationEmail(userActivation: UserActivationModel): Promise { + // Load the template for the email + const template = await readFile(`${__dirname}/../../views/emails/activation-email-html.mst`, "utf8"); + const websiteName = process.env["WEBSITE_NAME"] || "FSSK"; + + const user = await this.getUser(userActivation.get("user_id")); + + // Render our reset password template + let output; + try { + output = Mustache.render(template, { + name: user.get("name"), + websiteName, + activationUrl: `${process.env["SITE_URL"]}/activate-account/${userActivation.get("token")}`, + }); + } catch (error) { + throw new Error("Could not render e-mail template because: " + error); + } + + // Send the email + await sendEmail({ + subject: `Activate Account on ${websiteName}`, + from: `"${websiteName} Support" <${process.env["SUPPORT_EMAIL"] || ""}>`, + to: [user.get("email")], + text: output, + html: output, + }); + } } diff --git a/server/src/components/users/userActivationModel.ts b/server/src/components/users/userActivationModel.ts new file mode 100644 index 0000000..bf26446 --- /dev/null +++ b/server/src/components/users/userActivationModel.ts @@ -0,0 +1,11 @@ +import BaseModel from "../base/baseModel"; +import UserModel from "../users/userModel"; + +export default class UserActivationModel extends BaseModel { + get tableName() { return "user_activations"; } + get idAttribute() { return "token"; } + get hasTimestamps() { return true; } + public user() { + return this.belongsTo(UserModel, "user_id"); + } +} diff --git a/server/src/components/users/userModel.ts b/server/src/components/users/userModel.ts index 37d0b1b..d2a6206 100644 --- a/server/src/components/users/userModel.ts +++ b/server/src/components/users/userModel.ts @@ -1,8 +1,13 @@ import BaseModel from "../base/baseModel"; +import UserActivationModel from "./userActivationModel"; export default class UserModel extends BaseModel { get tableName() { return "users"; } get idAttribute() { return "id"; } get hasTimestamps() { return true; } get hidden() { return ["password"]; } // don't return the password as part of toJSON() calls + + public userActivation() { + return this.hasOne(UserActivationModel); + } } diff --git a/server/src/components/users/usersRouter.ts b/server/src/components/users/usersRouter.ts index 88f4fd7..ab9babe 100644 --- a/server/src/components/users/usersRouter.ts +++ b/server/src/components/users/usersRouter.ts @@ -2,11 +2,21 @@ import * as express from "express"; import {adminAuthMiddleware, authMiddleware} from "../auth"; import UsersController from "./index"; import TodosController from "../todos/index"; +import Database from "../../database"; +import * as ExpressBrute from "express-brute"; +import * as BruteKnex from "brute-knex"; +import * as Passport from "passport"; const router = express.Router(); const usersController = new UsersController(); const todosController = new TodosController(); +const store = new BruteKnex({ + createTable: true, + knex: Database.knex, +}); +const bruteforce = new ExpressBrute(store, { freeRetries: 2, minWait: 300000 }); + // only admin users may access this route router.get("/", adminAuthMiddleware, (req: express.Request, res: express.Response, next: express.NextFunction) => { return usersController.getUsers() @@ -44,4 +54,26 @@ router.post("/register", (req: express.Request, res: express.Response, next: exp .catch((err: Error) => next(err)); }); +router.get("/resend-activation/:email", bruteforce.prevent, + (req: express.Request, res: express.Response, next: express.NextFunction) => { + return usersController.resendActivationEmail(req.params.email) + .then((user) => res.json(user ? user.toJSON() : {})) + .catch((err: Error) => next(err)); +}); + +router.get("/activate/:token", (req: express.Request, res: express.Response, next: express.NextFunction) => { + return usersController.activateUser(req.params.token) + .then((activationResults) => { + if (activationResults && activationResults.isValid && activationResults.user) { + req.login(activationResults.user, function(err) { + if (err) { return next(err); } + res.json(activationResults); + }); + } else { + res.json({isValid: false}); + } + }) + .catch((err: Error) => next(err)); +}); + export default router; diff --git a/server/src/db-migrations/upgrades/20190401180528_userActivations.ts b/server/src/db-migrations/upgrades/20190401180528_userActivations.ts new file mode 100644 index 0000000..802ab12 --- /dev/null +++ b/server/src/db-migrations/upgrades/20190401180528_userActivations.ts @@ -0,0 +1,15 @@ +import * as Knex from "knex"; + +exports.up = function(knex: Knex): Promise { + return Promise.resolve(knex.schema.createTableIfNotExists("user_activations", (table) => { + table.uuid("token").primary().defaultTo(knex.raw("uuid_generate_v4()")); + table.uuid("user_id").notNullable(); + table.foreign("user_id").references("users.id"); + table.unique(["user_id"]); + table.timestamps(true); + })); +}; + +exports.down = function(knex: Knex): Promise { + return Promise.resolve(knex.schema.dropTable("user_activations")); +}; diff --git a/server/src/db-migrations/upgrades/20190401181200_addUserActivatedColumn.ts b/server/src/db-migrations/upgrades/20190401181200_addUserActivatedColumn.ts new file mode 100644 index 0000000..8c4e30c --- /dev/null +++ b/server/src/db-migrations/upgrades/20190401181200_addUserActivatedColumn.ts @@ -0,0 +1,13 @@ +import * as Knex from "knex"; + +exports.up = function(knex: Knex): Promise { + return Promise.resolve(knex.schema.table("users", (table) => { + table.boolean("activated").defaultTo(false); + })); +}; + +exports.down = function(knex: Knex): Promise { + return Promise.resolve(knex.schema.table("users", (table) => { + table.dropColumn("activated"); + })); +}; diff --git a/server/src/views/emails/activation-email-html.mst b/server/src/views/emails/activation-email-html.mst new file mode 100644 index 0000000..c7bae2c --- /dev/null +++ b/server/src/views/emails/activation-email-html.mst @@ -0,0 +1,6 @@ +

Hello {{name}},

+

Click the following link to confirm and activate your new account:

+ +

Activation Link

+ +

If the above link is not clickable, try copying and pasting it into the address bar of your web browser.