Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions models/passwordResetToken.ts
Original file line number Diff line number Diff line change
@@ -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
})
39 changes: 39 additions & 0 deletions routes/generatePasswordResetToken.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
28 changes: 21 additions & 7 deletions routes/resetPassword.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,24 @@ 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) => {
const email = body.email
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: [{
Expand All @@ -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)
})
Expand Down
23 changes: 23 additions & 0 deletions routes/validateResetToken.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
}