From 2361236c4e6bbb1be6f43ae436d716f893376e65 Mon Sep 17 00:00:00 2001 From: mamen Date: Tue, 3 Mar 2020 09:03:17 +0100 Subject: [PATCH 1/6] Started implementation --- src/App.ts | 4 +- src/common/Encryption.ts | 18 +++++ src/interfaces/IResetTokenService.ts | 6 ++ src/ioc/createContainer.ts | 2 + src/loggers/GraylogLogger.ts | 104 +++++++++++++++++++++++++++ src/routes/UsersRouter.ts | 45 ++++++++++++ src/services/ResetTokenService.ts | 49 +++++++++++++ src/units/ResetToken.ts | 50 +++++++++++++ 8 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 src/interfaces/IResetTokenService.ts create mode 100644 src/loggers/GraylogLogger.ts create mode 100644 src/services/ResetTokenService.ts create mode 100644 src/units/ResetToken.ts diff --git a/src/App.ts b/src/App.ts index 74011dc..2af896d 100644 --- a/src/App.ts +++ b/src/App.ts @@ -143,10 +143,12 @@ export default class App { ); // if the user tries to authenticate, we don't have a token yet + // TODO: Replace with whitelist of urls, which don't need a token if ( !request.originalUrl.toString().includes("/auth/") && !request.originalUrl.toString().includes("/users/create/") && - !request.originalUrl.toString().includes("/config/") + !request.originalUrl.toString().includes("/config/") && + !request.originalUrl.toString().includes("/user/forgot") ) { const authString = request.header("authorization"); diff --git a/src/common/Encryption.ts b/src/common/Encryption.ts index 3d2655c..9f6d0c5 100644 --- a/src/common/Encryption.ts +++ b/src/common/Encryption.ts @@ -1,4 +1,6 @@ const SALT_WORK_FACTOR = 10; +const crypto = require("crypto"); + let bcrypt; try { bcrypt = require("bcrypt"); @@ -26,4 +28,20 @@ export default class Encryption { public static async compare(password: string, hash: string): Promise { return bcrypt.compare(password, hash); } + + /** + * Generates a random token + * @param byteLength the length of the generated token + */ + public static async generateToken(byteLength = 128): Promise { + return new Promise((resolve, reject) => { + crypto.randomBytes(byteLength, (err, buffer) => { + if (err) { + reject(err); + } else { + resolve(buffer.toString("base64")); + } + }); + }); + } } diff --git a/src/interfaces/IResetTokenService.ts b/src/interfaces/IResetTokenService.ts new file mode 100644 index 0000000..713eddb --- /dev/null +++ b/src/interfaces/IResetTokenService.ts @@ -0,0 +1,6 @@ +import ResetToken from "../units/ResetToken"; + +export default interface IResetTokenService { + checkIfResetAlreadyRequested(email: string): Promise; + storeResetToken(data: ResetToken): Promise; +} diff --git a/src/ioc/createContainer.ts b/src/ioc/createContainer.ts index 13dde9c..c19c98a 100644 --- a/src/ioc/createContainer.ts +++ b/src/ioc/createContainer.ts @@ -8,6 +8,7 @@ import PlanetService from "../services/PlanetService"; import ShipService from "../services/ShipService"; import TechService from "../services/TechService"; import UserService from "../services/UserService"; +import ResetTokenService from "../services/ResetTokenService"; module.exports = function() { const container = new Container(); @@ -21,6 +22,7 @@ module.exports = function() { container.service("shipService", () => new ShipService()); container.service("techService", () => new TechService()); container.service("userService", () => new UserService()); + container.service("resetTokenService", () => new ResetTokenService()); return container; }; diff --git a/src/loggers/GraylogLogger.ts b/src/loggers/GraylogLogger.ts new file mode 100644 index 0000000..5dddf9f --- /dev/null +++ b/src/loggers/GraylogLogger.ts @@ -0,0 +1,104 @@ +import ILogger from "../interfaces/ILogger"; +import { Graylog } from "ts-graylog/dist"; +import { GraylogMessage } from "ts-graylog/dist/models/message.model"; +import { GraylogLevelEnum } from "ts-graylog/dist/models/enums.model"; + +/** + * This class represents a simple logger which logs + * straight to the this.graylog. + */ +export default class GraylogLogger implements ILogger { + private serverUrl: string; + private port: number; + private graylog: Graylog = new Graylog({ + servers: [{ host: "127.0.0.1", port: 12201 }], + hostname: "server.name", + bufferSize: 1350, + }); + + /** + * Creates a new GralogLogger instance + */ + public constructor() { + this.serverUrl = "0.0.0.0"; + this.port = 12201; + } + + /** + * Log a message of severity 'error' + * @param messageText the message + * @param stackTrace the stacktrace + */ + public error(messageText: string, stackTrace: string) { + const message = new GraylogMessage({ + version: "1.0", + short_message: messageText, + timestamp: Date.now() / 1000, + level: GraylogLevelEnum.ERROR, + facility: "api", + stack_trace: stackTrace, + }); + + this.graylog.error(message, null); + } + + /** + * Log a message of severity 'warn' + * @param messageText the message + */ + public warn(messageText: string) { + const message = new GraylogMessage({ + version: "1.0", + short_message: messageText, + timestamp: Date.now() / 1000, + level: GraylogLevelEnum.WARNING, + }); + + this.graylog.warning(message, null); + } + + /** + * Log a message of severity 'info' + * @param messageText the message + */ + public info(messageText: string) { + const message = new GraylogMessage({ + version: "1.0", + short_message: messageText, + timestamp: Date.now() / 1000, + level: GraylogLevelEnum.INFO, + }); + + this.graylog.info(message); + } + + /** + * Log a message of severity 'log' + * @param messageText the message + */ + public log(messageText: string) { + const message = new GraylogMessage({ + version: "1.0", + short_message: messageText, + timestamp: Date.now() / 1000, + level: GraylogLevelEnum.NOTICE, + }); + + this.graylog.log(message); + } + + /** + * Log a message of severity 'debug' + * @param messageText + */ + public debug(messageText: string) { + const message = new GraylogMessage({ + version: "1.0", + short_message: messageText, + timestamp: Date.now() / 1000, + level: GraylogLevelEnum.DEBUG, + }); + + this.graylog.log(message); + } +} diff --git a/src/routes/UsersRouter.ts b/src/routes/UsersRouter.ts index 689e4ba..cbad942 100644 --- a/src/routes/UsersRouter.ts +++ b/src/routes/UsersRouter.ts @@ -20,6 +20,9 @@ import PlanetsRouter from "./PlanetsRouter"; import JwtHelper from "../common/JwtHelper"; import PlanetType = Globals.PlanetType; import ILogger from "../interfaces/ILogger"; +import IResetTokenService from "../interfaces/IResetTokenService"; +import ResetToken from "../units/ResetToken"; +import { showRuleCrashWarning } from "tslint/lib/error"; /** * Defines routes for user-data @@ -36,6 +39,7 @@ export default class UsersRouter { private defenseService: IDefenseService; private shipService: IShipService; private techService: ITechService; + private resetTokenService: IResetTokenService; /** * Registers the routes and needed services @@ -50,6 +54,7 @@ export default class UsersRouter { this.defenseService = container.defenseService; this.shipService = container.shipService; this.techService = container.techService; + this.resetTokenService = container.resetTokenService; // /user/create/ this.router.post("/create", this.createUser); @@ -69,6 +74,9 @@ export default class UsersRouter { // /user/currentplanet/set/:planetID this.router.post("/currentplanet/set", this.setCurrentPlanet); + // /user/currentplanet/set/:planetID + this.router.post("/forgot", this.forgotPassword); + // /users/:userID this.router.get("/:userID", this.getUserByID); @@ -407,4 +415,41 @@ export default class UsersRouter { }); } }; + + public forgotPassword = async (request: IAuthorizedRequest, response: Response, next: NextFunction) => { + try { + // validate parameters + if (!InputValidator.isSet(request.body.email)) { + return response.status(Globals.Statuscode.BAD_REQUEST).json({ + error: "Invalid parameter", + }); + } + + const token: ResetToken = new ResetToken(); + + token.email = InputValidator.sanitizeString(request.body.email); + + if (await this.resetTokenService.checkIfResetAlreadyRequested(token.email)) { + return response.status(Globals.Statuscode.BAD_REQUEST).json({ + error: "A request for this mail-address was already made.", + }); + } + + token.ipRequested = request.ip; + token.requestedAt = Math.round(+new Date() / 1000); + token.resetToken = (await Encryption.generateToken()).substr(0, 64); + + await this.resetTokenService.storeResetToken(token); + + // TODO: send mail if the given mail is connected to a account + + return response.status(Globals.Statuscode.SUCCESS).json(token); + } catch (error) { + this.logger.error(error, error.stack); + + return response.status(Globals.Statuscode.SERVER_ERROR).json({ + error: "There was an error while handling the request.", + }); + } + }; } diff --git a/src/services/ResetTokenService.ts b/src/services/ResetTokenService.ts new file mode 100644 index 0000000..9a20e8b --- /dev/null +++ b/src/services/ResetTokenService.ts @@ -0,0 +1,49 @@ +import IResetTokenService from "../interfaces/IResetTokenService"; + +import squel = require("safe-squel"); +import Database from "../common/Database"; +import InputValidator from "../common/InputValidator"; +import ResetToken from "../units/ResetToken"; + +/** + * This class defines a service to interact with the ResetToken-table in the database + */ +export default class ResetTokenService implements IResetTokenService { + /** + * Checks, if a reset-request for the given email was already made + * @param email + */ + public async checkIfResetAlreadyRequested(email: string): Promise { + const query: string = squel + .select() + .from("resetTokens") + .field("email") + .where("email = ?", email) + .toString(); + + const [rows] = await Database.query(query.toString()); + + if (!InputValidator.isSet(rows)) { + return false; + } + + return rows.length > 0; + } + + /** + * Inserts a new token into the database + * @param data The token-data + */ + public async storeResetToken(data: ResetToken): Promise { + const query: string = squel + .insert({ autoQuoteFieldNames: true }) + .into("resetTokens") + .set("email", data.email) + .set("ipRequested", data.ipRequested) + .set("resetToken", data.resetToken) + .set("requestedAt", data.requestedAt) + .toString(); + + await Database.query(query.toString()); + } +} diff --git a/src/units/ResetToken.ts b/src/units/ResetToken.ts new file mode 100644 index 0000000..5d709ea --- /dev/null +++ b/src/units/ResetToken.ts @@ -0,0 +1,50 @@ +import IUnits from "../interfaces/IUnits"; +import InputValidator from "../common/InputValidator"; + +/** + * This class represents a row in the ResetTokens-table + */ +export default class ResetToken implements IUnits { + /** + * The Mail-Adress for which a new password is requested + */ + public email: string; + + /** + * The IP-Adress, from which the request was made + */ + public ipRequested: string; + + /** + * The token to reset the password + */ + public resetToken: string; + + /** + * The timestamp, at which the request was made + */ + public requestedAt: number; + + /** + * Returns, if the contains valid data or not + */ + public isValid(): boolean { + if (!InputValidator.isSet(this.email) || this.email.length > 64) { + return false; + } + + if (!InputValidator.isSet(this.ipRequested) || this.ipRequested.length > 45 || this.ipRequested.length < 7) { + return false; + } + + if (!InputValidator.isSet(this.resetToken) || this.resetToken.length > 64) { + return false; + } + + if (!InputValidator.isSet(this.requestedAt) || this.requestedAt <= 0) { + return false; + } + + return true; + } +} From de8633e6fbbd9a353fe8e38f810065bc0edb513a Mon Sep 17 00:00:00 2001 From: mamen Date: Tue, 3 Mar 2020 13:04:20 +0100 Subject: [PATCH 2/6] Added a route to set the new password with the given token Added check, if given token is still valid (requestedTime + x hours >= now) Added SQL of new table to schema.sql and ci.sql --- schema.sql | 12 ++++ src/App.ts | 3 +- src/interfaces/IResetTokenService.ts | 4 +- src/routes/UsersRouter.ts | 89 +++++++++++++++++++++++++--- src/services/ResetTokenService.ts | 46 +++++++++++++- src/services/UserService.ts | 6 +- src/units/ResetToken.ts | 10 ++++ test/ci.sql | 13 +++- 8 files changed, 166 insertions(+), 17 deletions(-) diff --git a/schema.sql b/schema.sql index 4b09b49..e958155 100644 --- a/schema.sql +++ b/schema.sql @@ -288,6 +288,18 @@ CREATE TABLE `techs` ( ) ENGINE=InnoDB DEFAULT CHARSET=latin1; /*!40101 SET character_set_client = @saved_cs_client */; +-- +-- Table structure for table `resetTokens` +-- + +CREATE TABLE `resetTokens` ( + `email` varchar(64) NOT NULL, + `ipRequested` varchar(45) NOT NULL, + `resetToken` varchar(64) NOT NULL, + `requestedAt` int(10) NOT NULL, + `usedAt` int(10) DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=latin1; + -- ----------------------------------------------------- -- procedure getFreePosition -- ----------------------------------------------------- diff --git a/src/App.ts b/src/App.ts index 2af896d..7d800ba 100644 --- a/src/App.ts +++ b/src/App.ts @@ -148,7 +148,8 @@ export default class App { !request.originalUrl.toString().includes("/auth/") && !request.originalUrl.toString().includes("/users/create/") && !request.originalUrl.toString().includes("/config/") && - !request.originalUrl.toString().includes("/user/forgot") + !request.originalUrl.toString().includes("/user/forgot") && + !request.originalUrl.toString().includes("/user/resetPassword") ) { const authString = request.header("authorization"); diff --git a/src/interfaces/IResetTokenService.ts b/src/interfaces/IResetTokenService.ts index 713eddb..5530cd1 100644 --- a/src/interfaces/IResetTokenService.ts +++ b/src/interfaces/IResetTokenService.ts @@ -1,6 +1,8 @@ import ResetToken from "../units/ResetToken"; export default interface IResetTokenService { - checkIfResetAlreadyRequested(email: string): Promise; + checkIfResetAlreadyRequested(email: string, lastValidTimestamp: number): Promise; + getTokenFromMail(email: string): Promise; + setTokenUsed(token: string, usedAt: number): Promise; storeResetToken(data: ResetToken): Promise; } diff --git a/src/routes/UsersRouter.ts b/src/routes/UsersRouter.ts index cbad942..8a00d63 100644 --- a/src/routes/UsersRouter.ts +++ b/src/routes/UsersRouter.ts @@ -22,7 +22,7 @@ import PlanetType = Globals.PlanetType; import ILogger from "../interfaces/ILogger"; import IResetTokenService from "../interfaces/IResetTokenService"; import ResetToken from "../units/ResetToken"; -import { showRuleCrashWarning } from "tslint/lib/error"; +import EntityInvalidException from "../exceptions/EntityInvalidException"; /** * Defines routes for user-data @@ -74,12 +74,15 @@ export default class UsersRouter { // /user/currentplanet/set/:planetID this.router.post("/currentplanet/set", this.setCurrentPlanet); - // /user/currentplanet/set/:planetID - this.router.post("/forgot", this.forgotPassword); - // /users/:userID this.router.get("/:userID", this.getUserByID); + // /user/forgot + this.router.post("/forgot", this.forgotPassword); + + // /user/reset + this.router.post("/resetPassword", this.resetPassword); + // /user this.router.get("/", this.getUserSelf); @@ -416,6 +419,68 @@ export default class UsersRouter { } }; + public resetPassword = async (request: IAuthorizedRequest, response: Response, next: NextFunction) => { + try { + if ( + !InputValidator.isSet(request.body.token) || + !InputValidator.isSet(request.body.email) || + !InputValidator.isSet(request.body.newPassword) + ) { + return response.status(Globals.Statuscode.BAD_REQUEST).json({ + error: "Invalid parameter", + }); + } + + const token = InputValidator.sanitizeString(request.body.token); + const email = InputValidator.sanitizeString(request.body.email); + + // all tokens, which are older than 24h are invalid + const lastValidTimestamp = Math.round(+new Date() / 1000) - 24 * 60 * 60; + + const matchingResetToken = await this.resetTokenService.getTokenFromMail(email); + + if (!InputValidator.isSet(matchingResetToken) || matchingResetToken.resetToken !== token) { + return response.status(Globals.Statuscode.BAD_REQUEST).json({ + error: "Invalid token", + }); + } + + if (matchingResetToken.requestedAt < lastValidTimestamp) { + return response.status(Globals.Statuscode.SUCCESS).json({ + error: "Token is not valid anymore", + }); + } + + if (InputValidator.isSet(matchingResetToken.usedAt)) { + return response.status(Globals.Statuscode.BAD_REQUEST).json({ + error: "Token was already used", + }); + } + + const newPassword = InputValidator.sanitizeString(request.body.newPassword); + + const user = await this.userService.getUserForAuthentication(email); + + user.password = await Encryption.hash(newPassword); + + await this.userService.updateUserData(user); + + const currentTimestamp: number = Math.round(+new Date() / 1000); + + await this.resetTokenService.setTokenUsed(matchingResetToken.resetToken, currentTimestamp); + + // TODO: send mail stating, that the password was reset + + return response.status(Globals.Statuscode.SUCCESS).json({}); + } catch (error) { + this.logger.error(error, error.stack); + + return response.status(Globals.Statuscode.SERVER_ERROR).json({ + error: "There was an error while handling the request.", + }); + } + }; + public forgotPassword = async (request: IAuthorizedRequest, response: Response, next: NextFunction) => { try { // validate parameters @@ -429,7 +494,10 @@ export default class UsersRouter { token.email = InputValidator.sanitizeString(request.body.email); - if (await this.resetTokenService.checkIfResetAlreadyRequested(token.email)) { + // all tokens, which are older than 24h are invalid + const lastValidTimestamp = Math.round(+new Date() / 1000) - 24 * 60 * 60; + + if (await this.resetTokenService.checkIfResetAlreadyRequested(token.email, lastValidTimestamp)) { return response.status(Globals.Statuscode.BAD_REQUEST).json({ error: "A request for this mail-address was already made.", }); @@ -437,13 +505,20 @@ export default class UsersRouter { token.ipRequested = request.ip; token.requestedAt = Math.round(+new Date() / 1000); - token.resetToken = (await Encryption.generateToken()).substr(0, 64); + + const generatedToken = await Encryption.generateToken(); + + token.resetToken = InputValidator.sanitizeString(generatedToken).substr(0, 64); + + if (!token.isValid()) { + throw new EntityInvalidException("The resetToken-entity is invalid."); + } await this.resetTokenService.storeResetToken(token); // TODO: send mail if the given mail is connected to a account - return response.status(Globals.Statuscode.SUCCESS).json(token); + return response.status(Globals.Statuscode.SUCCESS).json({}); } catch (error) { this.logger.error(error, error.stack); diff --git a/src/services/ResetTokenService.ts b/src/services/ResetTokenService.ts index 9a20e8b..82eb64b 100644 --- a/src/services/ResetTokenService.ts +++ b/src/services/ResetTokenService.ts @@ -4,6 +4,8 @@ import squel = require("safe-squel"); import Database from "../common/Database"; import InputValidator from "../common/InputValidator"; import ResetToken from "../units/ResetToken"; +import SerializationHelper from "../common/SerializationHelper"; +import User from "../units/User"; /** * This class defines a service to interact with the ResetToken-table in the database @@ -11,14 +13,17 @@ import ResetToken from "../units/ResetToken"; export default class ResetTokenService implements IResetTokenService { /** * Checks, if a reset-request for the given email was already made - * @param email + * @param email the email-address for which a password change will be requested + * @param lastValidTimestamp the latest timestamp, after which previously requested tokens have become invalid */ - public async checkIfResetAlreadyRequested(email: string): Promise { + public async checkIfResetAlreadyRequested(email: string, lastValidTimestamp: number): Promise { const query: string = squel .select() .from("resetTokens") .field("email") .where("email = ?", email) + .where("requestedAt >= ?", lastValidTimestamp) + .where("usedAt IS NULL") .toString(); const [rows] = await Database.query(query.toString()); @@ -30,6 +35,27 @@ export default class ResetTokenService implements IResetTokenService { return rows.length > 0; } + /** + * Checks, if the given token was issued for the given mail-address and if the token is still valid by checking the + * timestamp + * @param email + */ + public async getTokenFromMail(email: string): Promise { + const query: string = squel + .select() + .from("resetTokens") + .where("email = ?", email) + .toString(); + + const [rows] = await Database.query(query.toString()); + + if (!InputValidator.isSet(rows)) { + return null; + } + + return SerializationHelper.toInstance(new ResetToken(), JSON.stringify(rows[0])); + } + /** * Inserts a new token into the database * @param data The token-data @@ -46,4 +72,20 @@ export default class ResetTokenService implements IResetTokenService { await Database.query(query.toString()); } + + /** + * Marks a given token as used by setting the time it was used + * @param token the token + * @param usedAt the timestamp, at which the token was used + */ + public async setTokenUsed(token: string, usedAt: number): Promise { + const query: string = squel + .update({ autoQuoteFieldNames: true }) + .table("resetTokens") + .set("usedAt", usedAt) + .where("resetToken = ?", token) + .toString(); + + await Database.query(query.toString()); + } } diff --git a/src/services/UserService.ts b/src/services/UserService.ts index eb1b912..23898fa 100644 --- a/src/services/UserService.ts +++ b/src/services/UserService.ts @@ -4,6 +4,7 @@ import SerializationHelper from "../common/SerializationHelper"; import IUserService from "../interfaces/IUserService"; import User from "../units/User"; import squel = require("safe-squel"); +import EntityInvalidException from "../exceptions/EntityInvalidException"; /** * This class defines a service to interact with the users-table in the database @@ -142,11 +143,6 @@ export default class UserService implements IUserService { public async updateUserData(user: User, connection = null) { let query = squel.update().table("users"); - if (!user.isValid()) { - // TODO: throw exception - return null; - } - if (typeof user.username !== "undefined") { query = query.set("username", user.username); } diff --git a/src/units/ResetToken.ts b/src/units/ResetToken.ts index 5d709ea..8da54a6 100644 --- a/src/units/ResetToken.ts +++ b/src/units/ResetToken.ts @@ -25,6 +25,12 @@ export default class ResetToken implements IUnits { */ public requestedAt: number; + /** + * The timestamp, at which the token was used + * Default is set to null + */ + public usedAt: number; + /** * Returns, if the contains valid data or not */ @@ -45,6 +51,10 @@ export default class ResetToken implements IUnits { return false; } + if (InputValidator.isSet(this.usedAt) && this.requestedAt <= 0) { + return false; + } + return true; } } diff --git a/test/ci.sql b/test/ci.sql index 772b2ec..a4f5845 100644 --- a/test/ci.sql +++ b/test/ci.sql @@ -304,9 +304,20 @@ INSERT INTO `stats` VALUES (1,2220584795,1,1); INSERT INTO `techs` VALUES (1,1,23,23,23,23,23,23,23,23,23,23,23,23,21,1),(35,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0),(41,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0),(48,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0),(59,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0),(74,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0); INSERT INTO `events` VALUES (1, 1, 2, '{"201":612,"202":357,"203":617,"204":800,"205":709,"206":204,"207":703,"208":85,"209":631,"210":388,"211":0,"212":723,"213":557,"214":106}', 167546850, 1, 1563979907, 93133, 1, 1565146584, 443, 980, 220, 0, 0); - /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; +-- +-- Table structure for table `resetTokens` +-- + +CREATE TABLE `resetTokens` ( + `email` varchar(64) NOT NULL, + `ipRequested` varchar(45) NOT NULL, + `resetToken` varchar(64) NOT NULL, + `requestedAt` int(10) NOT NULL, + `usedAt` int(10) DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=latin1; + DELIMITER // CREATE PROCEDURE `getFreePosition`(IN maxGalaxy int, IN maxSystem int, IN minPlanet int, IN maxPlanet int) From 8c79d7cd43dcd58a0d13cf210b22d4f1e7246ac2 Mon Sep 17 00:00:00 2001 From: mamen Date: Tue, 3 Mar 2020 14:30:49 +0100 Subject: [PATCH 3/6] Added ability to send mails --- .env.ci.example | 11 ---------- .env.example | 9 ++++++++ package-lock.json | 5 +++++ package.json | 1 + src/common/MailSender.ts | 38 ++++++++++++++++++++++++++++++++++ src/interfaces/IUserService.ts | 1 + src/routes/UsersRouter.ts | 14 ++++++++++++- src/services/UserService.ts | 26 ++++++++++++++++++++++- 8 files changed, 92 insertions(+), 13 deletions(-) delete mode 100644 .env.ci.example create mode 100644 src/common/MailSender.ts diff --git a/.env.ci.example b/.env.ci.example deleted file mode 100644 index 1c9d4ed..0000000 --- a/.env.ci.example +++ /dev/null @@ -1,11 +0,0 @@ -JWT_SECRET=thisismysupersecretkey - -DB_HOST=localhost -DB_PORT=3306 -DB_NAME=ugamela -DB_USER=root -DB_PASS=root - -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_PASSWORD=secret diff --git a/.env.example b/.env.example index 1c9d4ed..05ac8fe 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,12 @@ DB_PASS=root REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD=secret + +MAIL_FROM=no-reply@yourdomain.tld +MAIL_SUPPORT=support@yourdomain.tld + +SMTP_HOST=smtp.yourdomain.tld +SMTP_PORT=465 +SMTP_USERNAME=yourUsername +SMTP_PASSWORD=yourPassword + diff --git a/package-lock.json b/package-lock.json index 6c7650c..da273e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5477,6 +5477,11 @@ "tar": "^4.4.2" } }, + "nodemailer": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.4.4.tgz", + "integrity": "sha512-2GqGu5o3FBmDibczU3+LZh9lCEiKmNx7LvHl512p8Kj+Kn5FQVOICZv85MDFz/erK0BDd5EJp3nqQLpWCZD1Gg==" + }, "nodemon": { "version": "1.19.4", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-1.19.4.tgz", diff --git a/package.json b/package.json index acab501..64927e0 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "morgan": "^1.9.1", "mysql2": "^1.6.5", "node-pre-gyp": "^0.14.0", + "nodemailer": "^6.4.4", "redis": "^2.8.0", "safe-squel": "^5.12.4", "ts-graylog": "^1.0.2", diff --git a/src/common/MailSender.ts b/src/common/MailSender.ts new file mode 100644 index 0000000..206c7c7 --- /dev/null +++ b/src/common/MailSender.ts @@ -0,0 +1,38 @@ +import dotenv = require("dotenv"); + +const nodemailer = require("nodemailer"); + +dotenv.config(); + +/** + * This is a wrapper-class for node-mailer + */ +export default class MailSender { + /** + * Sends a mail to the given address + * @param recipient + * @param subject + * @param body + */ + public static async sendMail(recipient: string, subject: string, body: string): Promise { + const mailOptions = { + from: process.env.MAIL_FROM, + to: recipient, + subject: subject, + replyTo: process.env.SUPPORT_MAIL, + html: body, + }; + + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT, + secure: true, // use SSL + auth: { + user: process.env.SMTP_USERNAME, + pass: process.env.SMTP_PASSWORD, + }, + }); + + await transporter.sendMail(mailOptions); + } +} diff --git a/src/interfaces/IUserService.ts b/src/interfaces/IUserService.ts index 2209eee..6c6f6b6 100644 --- a/src/interfaces/IUserService.ts +++ b/src/interfaces/IUserService.ts @@ -5,6 +5,7 @@ export default interface IUserService { getUserById(userID: number): Promise; getUserForAuthentication(email: string): Promise; checkIfNameOrMailIsTaken(username: string, email: string); + getUserByMail(email: string): Promise; getNewId(): Promise; createNewUser(user: User, connection?); updateUserData(user: User, connection?); diff --git a/src/routes/UsersRouter.ts b/src/routes/UsersRouter.ts index 8a00d63..f7dce4f 100644 --- a/src/routes/UsersRouter.ts +++ b/src/routes/UsersRouter.ts @@ -23,6 +23,7 @@ import ILogger from "../interfaces/ILogger"; import IResetTokenService from "../interfaces/IResetTokenService"; import ResetToken from "../units/ResetToken"; import EntityInvalidException from "../exceptions/EntityInvalidException"; +import MailSender from "../common/MailSender"; /** * Defines routes for user-data @@ -494,6 +495,14 @@ export default class UsersRouter { token.email = InputValidator.sanitizeString(request.body.email); + const user = await this.userService.getUserByMail(token.email); + + if (!InputValidator.isSet(user)) { + return response.status(Globals.Statuscode.BAD_REQUEST).json({ + error: "Invalid parameter", + }); + } + // all tokens, which are older than 24h are invalid const lastValidTimestamp = Math.round(+new Date() / 1000) - 24 * 60 * 60; @@ -516,7 +525,10 @@ export default class UsersRouter { await this.resetTokenService.storeResetToken(token); - // TODO: send mail if the given mail is connected to a account + // TODO: make this a template + const messageBody = `

Hello ${user.username},

a request has been made to reset your password.

To reset your password, click here.

If you did not request this reset of your password, ignore this mail. If this occures often, please contact the support.

You can reach the support by sending a mail to support@ugamela.org or join our discord-server.


`; + + await MailSender.sendMail(token.email, "Reset your ugamela password", messageBody); return response.status(Globals.Statuscode.SUCCESS).json({}); } catch (error) { diff --git a/src/services/UserService.ts b/src/services/UserService.ts index 23898fa..2539e27 100644 --- a/src/services/UserService.ts +++ b/src/services/UserService.ts @@ -4,7 +4,6 @@ import SerializationHelper from "../common/SerializationHelper"; import IUserService from "../interfaces/IUserService"; import User from "../units/User"; import squel = require("safe-squel"); -import EntityInvalidException from "../exceptions/EntityInvalidException"; /** * This class defines a service to interact with the users-table in the database @@ -52,6 +51,31 @@ export default class UserService implements IUserService { return SerializationHelper.toInstance(new User(), JSON.stringify(result[0])); } + /** + * Returns information about a user. + * This information does not contain sensible data (like email or passwords). + * @param email + */ + public async getUserByMail(email: string): Promise { + const query: string = squel + .select() + .distinct() + .field("userID") + .field("username") + .field("email") + .from("users") + .where("email = ?", email) + .toString(); + + const [result] = await Database.query(query); + + if (!InputValidator.isSet(result)) { + return null; + } + + return SerializationHelper.toInstance(new User(), JSON.stringify(result[0])); + } + /** * Returns informations about a user. * This information does contain sensible data which is needed for authentication (like email or passwords). From 32d0c0839594ef65900ef3021412fdf13419ed6e Mon Sep 17 00:00:00 2001 From: mamen Date: Tue, 3 Mar 2020 15:01:45 +0100 Subject: [PATCH 4/6] Added template for reset-password-request --- gulpfile.js | 4 ++- src/common/MailSender.ts | 6 ++-- src/routes/UsersRouter.ts | 13 ++++++-- src/templates/mail/resetPasswordRequest.html | 33 ++++++++++++++++++++ 4 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 src/templates/mail/resetPasswordRequest.html diff --git a/gulpfile.js b/gulpfile.js index 7db415c..b95139e 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -5,6 +5,7 @@ const JSON_FILES = ["src/*.json", "src/**/*.json"]; const TEST_FILES = ["src/**/*.spec.ts", "src/**/*.test.ts"]; const CONFIG_FILES = ["src/config/**/*"]; const SCHEMA_FILES = ["src/schemas/**/*"]; +const TEMPLATE_FILES = ["src/templates/**/*"]; const SOURCE_FILES = ["src/**/*.ts", "!" + TEST_FILES]; // pull in the project TypeScript config @@ -55,8 +56,9 @@ gulp.task("doc", function() { gulp.task("copy:config", () => gulp.src(CONFIG_FILES).pipe(gulp.dest("dist/config"))); gulp.task("copy:schema", () => gulp.src(SCHEMA_FILES).pipe(gulp.dest("dist/schemas"))); gulp.task("copy:json", () => gulp.src(JSON_FILES).pipe(gulp.dest("dist"))); +gulp.task("copy:templates", () => gulp.src(TEMPLATE_FILES).pipe(gulp.dest("dist/templates"))); -gulp.task("assets", gulp.series(["copy:config", "copy:schema", "copy:json"])); +gulp.task("assets", gulp.series(["copy:config", "copy:schema", "copy:json", "copy:templates"])); gulp.task("build", gulp.series("assets", "compile")); gulp.task("default", gulp.series("build", "watch")); diff --git a/src/common/MailSender.ts b/src/common/MailSender.ts index 206c7c7..e9a24d5 100644 --- a/src/common/MailSender.ts +++ b/src/common/MailSender.ts @@ -11,14 +11,14 @@ export default class MailSender { /** * Sends a mail to the given address * @param recipient - * @param subject + * @param subjectText * @param body */ - public static async sendMail(recipient: string, subject: string, body: string): Promise { + public static async sendMail(recipient: string, subjectText: string, body: string): Promise { const mailOptions = { from: process.env.MAIL_FROM, to: recipient, - subject: subject, + subject: subjectText, replyTo: process.env.SUPPORT_MAIL, html: body, }; diff --git a/src/routes/UsersRouter.ts b/src/routes/UsersRouter.ts index f7dce4f..8e5853c 100644 --- a/src/routes/UsersRouter.ts +++ b/src/routes/UsersRouter.ts @@ -24,6 +24,9 @@ import IResetTokenService from "../interfaces/IResetTokenService"; import ResetToken from "../units/ResetToken"; import EntityInvalidException from "../exceptions/EntityInvalidException"; import MailSender from "../common/MailSender"; +import dotenv = require("dotenv"); + +dotenv.config(); /** * Defines routes for user-data @@ -525,8 +528,14 @@ export default class UsersRouter { await this.resetTokenService.storeResetToken(token); - // TODO: make this a template - const messageBody = `

Hello ${user.username},

a request has been made to reset your password.

To reset your password, click here.

If you did not request this reset of your password, ignore this mail. If this occures often, please contact the support.

You can reach the support by sending a mail to support@ugamela.org or join our discord-server.


`; + const fs = require("fs"); + + const messageBody = fs + .readFileSync("dist/templates/mail/resetPasswordRequest.html", "utf-8") + .toString() + .replace("{{USERNAME}}", user.username) + .replace("{{TOKEN}}", token.resetToken) + .replace("{{SUPPORT_MAIL}}", process.env.MAIL_SUPPORT); await MailSender.sendMail(token.email, "Reset your ugamela password", messageBody); diff --git a/src/templates/mail/resetPasswordRequest.html b/src/templates/mail/resetPasswordRequest.html new file mode 100644 index 0000000..e830963 --- /dev/null +++ b/src/templates/mail/resetPasswordRequest.html @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + +
+
+
Hello %USERNAME%, +

a request has been made to reset your password.

To reset your password, + click here.

If you did not request this reset of your password, + ignore this mail. If this occures often, + please contact the support.

You can reach the support by sending a mail to support@ugamela.orgor join our discord-server.


+
+ + From 586bc3364f23f89c47d4df2ad3a8290e391dfccf Mon Sep 17 00:00:00 2001 From: mamen Date: Tue, 3 Mar 2020 15:05:16 +0100 Subject: [PATCH 5/6] Added template for reset-password-success An email is now sent after successfully resetting the password --- src/routes/UsersRouter.ts | 10 +++++- src/templates/mail/resetPasswordSuccess.html | 32 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 src/templates/mail/resetPasswordSuccess.html diff --git a/src/routes/UsersRouter.ts b/src/routes/UsersRouter.ts index 8e5853c..2c97364 100644 --- a/src/routes/UsersRouter.ts +++ b/src/routes/UsersRouter.ts @@ -473,7 +473,15 @@ export default class UsersRouter { await this.resetTokenService.setTokenUsed(matchingResetToken.resetToken, currentTimestamp); - // TODO: send mail stating, that the password was reset + const fs = require("fs"); + + const messageBody = fs + .readFileSync("dist/templates/mail/resetPasswordSuccess.html", "utf-8") + .toString() + .replace("{{USERNAME}}", user.username) + .replace("{{SUPPORT_MAIL}}", process.env.MAIL_SUPPORT); + + await MailSender.sendMail(user.email, "Your ugamela password has been reset", messageBody); return response.status(Globals.Statuscode.SUCCESS).json({}); } catch (error) { diff --git a/src/templates/mail/resetPasswordSuccess.html b/src/templates/mail/resetPasswordSuccess.html new file mode 100644 index 0000000..185363d --- /dev/null +++ b/src/templates/mail/resetPasswordSuccess.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + +
+

+ Hello {{USERNAME}},

+ your password has been reset successfully.

+ If you did not request this reset of your password, please contact the support immediately.

+ You can reach the support by sending a mail to support@ugamela.org or join our discord-server.


+
+ + From ee8a4e30c9648df839b7c9bdc11c081c11f439f5 Mon Sep 17 00:00:00 2001 From: mamen Date: Fri, 6 Mar 2020 18:19:43 +0100 Subject: [PATCH 6/6] Renamed routes --- src/routes/UsersRouter.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/UsersRouter.ts b/src/routes/UsersRouter.ts index 2c97364..8563246 100644 --- a/src/routes/UsersRouter.ts +++ b/src/routes/UsersRouter.ts @@ -81,10 +81,10 @@ export default class UsersRouter { // /users/:userID this.router.get("/:userID", this.getUserByID); - // /user/forgot - this.router.post("/forgot", this.forgotPassword); + // /user/forgotPassword + this.router.post("/forgotPassword", this.forgotPassword); - // /user/reset + // /user/resetPassword this.router.post("/resetPassword", this.resetPassword); // /user