diff --git a/models/passwordResetToken.ts b/models/passwordResetToken.ts new file mode 100644 index 00000000000..f770219ca0f --- /dev/null +++ b/models/passwordResetToken.ts @@ -0,0 +1,33 @@ +import { DataTypes, Model } from 'sequelize' +import { sequelize } from '../models/index' + +export class PasswordResetTokenModel extends Model { + public token!: string + public userId!: number + public expiresAt!: Date + public used!: boolean +} + +PasswordResetTokenModel.init({ + token: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: false + }, + used: { + type: DataTypes.BOOLEAN, + defaultValue: false + } +}, { + sequelize, + modelName: 'PasswordResetToken', + timestamps: false +}) diff --git a/routes/generatePasswordResetToken.ts b/routes/generatePasswordResetToken.ts new file mode 100644 index 00000000000..ee6a957d719 --- /dev/null +++ b/routes/generatePasswordResetToken.ts @@ -0,0 +1,39 @@ +import crypto from 'crypto' +import { type Request, type Response, type NextFunction } from 'express' +import { UserModel } from '../models/user' +import { PasswordResetTokenModel } from '../models/passwordResetToken' + +const rateLimitMap: { [key: string]: { count: number; firstRequestAt: Date } } = {} + +module.exports = function generatePasswordResetToken () { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const email = req.body.email + if (!email) { + return res.status(400).send(res.__('Email is required.')) + } + const now = new Date() + const rate = rateLimitMap[email] || { count: 0, firstRequestAt: now } + if (rate.firstRequestAt.getTime() + 3600000 < now.getTime()) { + rate.count = 0 + rate.firstRequestAt = now + } + rate.count++ + rateLimitMap[email] = rate + if (rate.count > 5) { + return res.status(429).send(res.__('Too many requests.')) + } + const user = await UserModel.findOne({ where: { email } }) + if (!user) { + return res.status(404).send(res.__('User not found.')) + } + const token = crypto.randomBytes(32).toString('hex') + const expiresAt = new Date(Date.now() + 3600000) + await PasswordResetTokenModel.create({ token, userId: user.id, expiresAt }) + // TODO: send email with reset link containing token + res.json({ token }) + } catch (error) { + next(error) + } + } +} diff --git a/routes/resetPassword.ts b/routes/resetPassword.ts index 235be1b45ee..273432d5bd3 100644 --- a/routes/resetPassword.ts +++ b/routes/resetPassword.ts @@ -13,6 +13,8 @@ import { challenges } from '../data/datacache' import challengeUtils = require('../lib/challengeUtils') const users = require('../data/datacache').users const security = require('../lib/insecurity') +import { PasswordResetTokenModel } from '../models/passwordResetToken' +import { Op } from 'sequelize' module.exports = function resetPassword () { return ({ body, connection }: Request, res: Response, next: NextFunction) => { @@ -20,12 +22,15 @@ module.exports = function resetPassword () { const answer = body.answer const newPassword = body.new const repeatPassword = body.repeat + const token = body.token if (!email || !answer) { next(new Error('Blocked illegal activity by ' + connection.remoteAddress)) } else if (!newPassword || newPassword === 'undefined') { res.status(401).send(res.__('Password cannot be empty.')) } else if (newPassword !== repeatPassword) { res.status(401).send(res.__('New and repeated password do not match.')) + } else if (!token || token === 'undefined') { + res.status(401).send(res.__('Reset token cannot be empty.')) } else { SecurityAnswerModel.findOne({ include: [{ @@ -34,13 +39,22 @@ module.exports = function resetPassword () { }] }).then((data: SecurityAnswerModel | null) => { if ((data != null) && security.hmac(answer) === data.answer) { - UserModel.findByPk(data.UserId).then((user: UserModel | null) => { - user?.update({ password: newPassword }).then((user: UserModel) => { - verifySecurityAnswerChallenges(user, answer) - res.json({ user }) - }).catch((error: unknown) => { - next(error) - }) + PasswordResetTokenModel.findOne({ where: { token, used: false, expiresAt: { [Op.gt]: new Date() } } }).then((tokenRecord: any) => { + if (!tokenRecord) { + res.status(401).send(res.__('Invalid or expired token.')) + } else { + UserModel.findByPk(data.UserId).then((user: UserModel | null) => { + user?.update({ password: newPassword }).then((user: UserModel) => { + verifySecurityAnswerChallenges(user, answer) + tokenRecord.update({ used: true }).catch(() => {}) + res.json({ user }) + }).catch((error: unknown) => { + next(error) + }) + }).catch((error: unknown) => { + next(error) + }) + } }).catch((error: unknown) => { next(error) }) diff --git a/routes/validateResetToken.ts b/routes/validateResetToken.ts new file mode 100644 index 00000000000..07d3d6cf05f --- /dev/null +++ b/routes/validateResetToken.ts @@ -0,0 +1,23 @@ +import { type Request, type Response, type NextFunction } from 'express' +import { PasswordResetTokenModel } from '../models/passwordResetToken' +import { Op } from 'sequelize' + +module.exports = function validateResetToken () { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const token = req.body.token + if (!token) { + return res.status(400).send(res.__('Token is required.')) + } + const record = await PasswordResetTokenModel.findOne({ + where: { token, used: false, expiresAt: { [Op.gt]: new Date() } } + }) + if (!record) { + return res.status(400).send(res.__('Invalid or expired token.')) + } + res.json({ valid: true, userId: record.userId }) + } catch (error) { + next(error) + } + } +}