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/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/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/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 74011dc..7d800ba 100644 --- a/src/App.ts +++ b/src/App.ts @@ -143,10 +143,13 @@ 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") && + !request.originalUrl.toString().includes("/user/resetPassword") ) { 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/common/MailSender.ts b/src/common/MailSender.ts new file mode 100644 index 0000000..e9a24d5 --- /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 subjectText + * @param body + */ + public static async sendMail(recipient: string, subjectText: string, body: string): Promise { + const mailOptions = { + from: process.env.MAIL_FROM, + to: recipient, + subject: subjectText, + 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/IResetTokenService.ts b/src/interfaces/IResetTokenService.ts new file mode 100644 index 0000000..5530cd1 --- /dev/null +++ b/src/interfaces/IResetTokenService.ts @@ -0,0 +1,8 @@ +import ResetToken from "../units/ResetToken"; + +export default interface IResetTokenService { + checkIfResetAlreadyRequested(email: string, lastValidTimestamp: number): Promise; + getTokenFromMail(email: string): Promise; + setTokenUsed(token: string, usedAt: number): Promise; + storeResetToken(data: ResetToken): Promise; +} 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/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..8563246 100644 --- a/src/routes/UsersRouter.ts +++ b/src/routes/UsersRouter.ts @@ -20,6 +20,13 @@ 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 EntityInvalidException from "../exceptions/EntityInvalidException"; +import MailSender from "../common/MailSender"; +import dotenv = require("dotenv"); + +dotenv.config(); /** * Defines routes for user-data @@ -36,6 +43,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 +58,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); @@ -72,6 +81,12 @@ export default class UsersRouter { // /users/:userID this.router.get("/:userID", this.getUserByID); + // /user/forgotPassword + this.router.post("/forgotPassword", this.forgotPassword); + + // /user/resetPassword + this.router.post("/resetPassword", this.resetPassword); + // /user this.router.get("/", this.getUserSelf); @@ -407,4 +422,138 @@ 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); + + 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) { + 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 + 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); + + 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; + + 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.", + }); + } + + token.ipRequested = request.ip; + token.requestedAt = Math.round(+new Date() / 1000); + + 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); + + 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); + + 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.", + }); + } + }; } diff --git a/src/services/ResetTokenService.ts b/src/services/ResetTokenService.ts new file mode 100644 index 0000000..82eb64b --- /dev/null +++ b/src/services/ResetTokenService.ts @@ -0,0 +1,91 @@ +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"; +import SerializationHelper from "../common/SerializationHelper"; +import User from "../units/User"; + +/** + * 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 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, 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()); + + if (!InputValidator.isSet(rows)) { + return false; + } + + 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 + */ + 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()); + } + + /** + * 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..2539e27 100644 --- a/src/services/UserService.ts +++ b/src/services/UserService.ts @@ -51,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). @@ -142,11 +167,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/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.


+
+ + 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.


+
+ + diff --git a/src/units/ResetToken.ts b/src/units/ResetToken.ts new file mode 100644 index 0000000..8da54a6 --- /dev/null +++ b/src/units/ResetToken.ts @@ -0,0 +1,60 @@ +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; + + /** + * The timestamp, at which the token was used + * Default is set to null + */ + public usedAt: 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; + } + + 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)