From ecb4e35f4e6c7304cb274593c13cb47fd5078b75 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 30 Jan 2018 13:27:07 +0100 Subject: Add ability to reset our password --- server/controllers/api/users.ts | 39 +++++++++++- server/helpers/logger.ts | 1 + server/initializers/checker.ts | 3 +- server/initializers/constants.ts | 20 ++++++- server/lib/emailer.ts | 106 +++++++++++++++++++++++++++++++++ server/lib/job-queue/handlers/email.ts | 22 +++++++ server/lib/job-queue/job-queue.ts | 9 ++- server/lib/redis.ts | 84 ++++++++++++++++++++++++++ server/middlewares/validators/users.ts | 93 ++++++++++++++++++++++++----- server/models/account/user.ts | 10 ++++ 10 files changed, 364 insertions(+), 23 deletions(-) create mode 100644 server/lib/emailer.ts create mode 100644 server/lib/job-queue/handlers/email.ts create mode 100644 server/lib/redis.ts (limited to 'server') diff --git a/server/controllers/api/users.ts b/server/controllers/api/users.ts index 79bb2665d..05639fbec 100644 --- a/server/controllers/api/users.ts +++ b/server/controllers/api/users.ts @@ -6,17 +6,23 @@ import { UserCreate, UserRight, UserRole, UserUpdate, UserUpdateMe, UserVideoRat import { unlinkPromise } from '../../helpers/core-utils' import { retryTransactionWrapper } from '../../helpers/database-utils' import { logger } from '../../helpers/logger' -import { createReqFiles, getFormattedObjects } from '../../helpers/utils' +import { createReqFiles, generateRandomString, getFormattedObjects } from '../../helpers/utils' import { AVATAR_MIMETYPE_EXT, AVATARS_SIZE, CONFIG, sequelizeTypescript } from '../../initializers' import { updateActorAvatarInstance } from '../../lib/activitypub' import { sendUpdateUser } from '../../lib/activitypub/send' +import { Emailer } from '../../lib/emailer' +import { EmailPayload } from '../../lib/job-queue/handlers/email' +import { Redis } from '../../lib/redis' import { createUserAccountAndChannel } from '../../lib/user' import { asyncMiddleware, authenticate, ensureUserHasRight, ensureUserRegistrationAllowed, paginationValidator, setDefaultSort, setDefaultPagination, token, usersAddValidator, usersGetValidator, usersRegisterValidator, usersRemoveValidator, usersSortValidator, usersUpdateMeValidator, usersUpdateValidator, usersVideoRatingValidator } from '../../middlewares' -import { usersUpdateMyAvatarValidator, videosSortValidator } from '../../middlewares/validators' +import { + usersAskResetPasswordValidator, usersResetPasswordValidator, usersUpdateMyAvatarValidator, + videosSortValidator +} from '../../middlewares/validators' import { AccountVideoRateModel } from '../../models/account/account-video-rate' import { UserModel } from '../../models/account/user' import { OAuthTokenModel } from '../../models/oauth/oauth-token' @@ -106,6 +112,16 @@ usersRouter.delete('/:id', asyncMiddleware(removeUser) ) +usersRouter.post('/ask-reset-password', + asyncMiddleware(usersAskResetPasswordValidator), + asyncMiddleware(askResetUserPassword) +) + +usersRouter.post('/:id/reset-password', + asyncMiddleware(usersResetPasswordValidator), + asyncMiddleware(resetUserPassword) +) + usersRouter.post('/token', token, success) // TODO: Once https://github.com/oauthjs/node-oauth2-server/pull/289 is merged, implement revoke token route @@ -307,6 +323,25 @@ async function updateUser (req: express.Request, res: express.Response, next: ex return res.sendStatus(204) } +async function askResetUserPassword (req: express.Request, res: express.Response, next: express.NextFunction) { + const user = res.locals.user as UserModel + + const verificationString = await Redis.Instance.setResetPasswordVerificationString(user.id) + const url = CONFIG.WEBSERVER.URL + '/reset-password?userId=' + user.id + '&verificationString=' + verificationString + await Emailer.Instance.addForgetPasswordEmailJob(user.email, url) + + return res.status(204).end() +} + +async function resetUserPassword (req: express.Request, res: express.Response, next: express.NextFunction) { + const user = res.locals.user as UserModel + user.password = req.body.password + + await user.save() + + return res.status(204).end() +} + function success (req: express.Request, res: express.Response, next: express.NextFunction) { res.end() } diff --git a/server/helpers/logger.ts b/server/helpers/logger.ts index 10e8cabc8..c353f55da 100644 --- a/server/helpers/logger.ts +++ b/server/helpers/logger.ts @@ -26,6 +26,7 @@ const loggerFormat = winston.format.printf((info) => { if (additionalInfos === '{}') additionalInfos = '' else additionalInfos = ' ' + additionalInfos + if (info.message.stack !== undefined) info.message = info.message.stack return `[${info.label}] ${info.timestamp} ${info.level}: ${info.message}${additionalInfos}` }) diff --git a/server/initializers/checker.ts b/server/initializers/checker.ts index 35fab244c..d550fd23f 100644 --- a/server/initializers/checker.ts +++ b/server/initializers/checker.ts @@ -22,7 +22,8 @@ function checkMissedConfig () { 'webserver.https', 'webserver.hostname', 'webserver.port', 'database.hostname', 'database.port', 'database.suffix', 'database.username', 'database.password', 'storage.videos', 'storage.logs', 'storage.thumbnails', 'storage.previews', 'storage.torrents', 'storage.cache', 'log.level', - 'cache.previews.size', 'admin.email', 'signup.enabled', 'signup.limit', 'transcoding.enabled', 'transcoding.threads', 'user.video_quota' + 'cache.previews.size', 'admin.email', 'signup.enabled', 'signup.limit', 'transcoding.enabled', 'transcoding.threads', + 'user.video_quota', 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address' ] const miss: string[] = [] diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 03828f54f..e7b1656e2 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -65,13 +65,15 @@ const JOB_ATTEMPTS: { [ id in JobType ]: number } = { 'activitypub-http-broadcast': 5, 'activitypub-http-unicast': 5, 'activitypub-http-fetcher': 5, - 'video-file': 1 + 'video-file': 1, + 'email': 5 } const JOB_CONCURRENCY: { [ id in JobType ]: number } = { 'activitypub-http-broadcast': 1, 'activitypub-http-unicast': 5, 'activitypub-http-fetcher': 1, - 'video-file': 1 + 'video-file': 1, + 'email': 5 } // 2 days const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2 @@ -95,9 +97,18 @@ const CONFIG = { }, REDIS: { HOSTNAME: config.get('redis.hostname'), - PORT: config.get('redis.port'), + PORT: config.get('redis.port'), AUTH: config.get('redis.auth') }, + SMTP: { + HOSTNAME: config.get('smtp.hostname'), + PORT: config.get('smtp.port'), + USERNAME: config.get('smtp.username'), + PASSWORD: config.get('smtp.password'), + TLS: config.get('smtp.tls'), + CA_FILE: config.get('smtp.ca_file'), + FROM_ADDRESS: config.get('smtp.from_address') + }, STORAGE: { AVATARS_DIR: buildPath(config.get('storage.avatars')), LOG_DIR: buildPath(config.get('storage.logs')), @@ -311,6 +322,8 @@ const PRIVATE_RSA_KEY_SIZE = 2048 // Password encryption const BCRYPT_SALT_SIZE = 10 +const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes + // --------------------------------------------------------------------------- // Express static paths (router) @@ -408,6 +421,7 @@ export { VIDEO_LICENCES, VIDEO_RATE_TYPES, VIDEO_MIMETYPE_EXT, + USER_PASSWORD_RESET_LIFETIME, AVATAR_MIMETYPE_EXT, SCHEDULER_INTERVAL, JOB_COMPLETED_LIFETIME diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts new file mode 100644 index 000000000..f5b68640e --- /dev/null +++ b/server/lib/emailer.ts @@ -0,0 +1,106 @@ +import { createTransport, Transporter } from 'nodemailer' +import { isTestInstance } from '../helpers/core-utils' +import { logger } from '../helpers/logger' +import { CONFIG } from '../initializers' +import { JobQueue } from './job-queue' +import { EmailPayload } from './job-queue/handlers/email' +import { readFileSync } from 'fs' + +class Emailer { + + private static instance: Emailer + private initialized = false + private transporter: Transporter + + private constructor () {} + + init () { + // Already initialized + if (this.initialized === true) return + this.initialized = true + + if (CONFIG.SMTP.HOSTNAME && CONFIG.SMTP.PORT) { + logger.info('Using %s:%s as SMTP server.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT) + + let tls + if (CONFIG.SMTP.CA_FILE) { + tls = { + ca: [ readFileSync(CONFIG.SMTP.CA_FILE) ] + } + } + + this.transporter = createTransport({ + host: CONFIG.SMTP.HOSTNAME, + port: CONFIG.SMTP.PORT, + secure: CONFIG.SMTP.TLS, + tls, + auth: { + user: CONFIG.SMTP.USERNAME, + pass: CONFIG.SMTP.PASSWORD + } + }) + } else { + if (!isTestInstance()) { + logger.error('Cannot use SMTP server because of lack of configuration. PeerTube will not be able to send mails!') + } + } + } + + async checkConnectionOrDie () { + if (!this.transporter) return + + try { + const success = await this.transporter.verify() + if (success !== true) this.dieOnConnectionFailure() + + logger.info('Successfully connected to SMTP server.') + } catch (err) { + this.dieOnConnectionFailure(err) + } + } + + addForgetPasswordEmailJob (to: string, resetPasswordUrl: string) { + const text = `Hi dear user,\n\n` + + `It seems you forgot your password on ${CONFIG.WEBSERVER.HOST}! ` + + `Please follow this link to reset it: ${resetPasswordUrl}.\n\n` + + `If you are not the person who initiated this request, please ignore this email.\n\n` + + `Cheers,\n` + + `PeerTube.` + + const emailPayload: EmailPayload = { + to: [ to ], + subject: 'Reset your PeerTube password', + text + } + + return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) + } + + sendMail (to: string[], subject: string, text: string) { + if (!this.transporter) { + throw new Error('Cannot send mail because SMTP is not configured.') + } + + return this.transporter.sendMail({ + from: CONFIG.SMTP.FROM_ADDRESS, + to: to.join(','), + subject, + text + }) + } + + private dieOnConnectionFailure (err?: Error) { + logger.error('Failed to connect to SMTP %s:%d.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT, err) + process.exit(-1) + } + + static get Instance () { + return this.instance || (this.instance = new this()) + } +} + +// --------------------------------------------------------------------------- + +export { + Emailer +} diff --git a/server/lib/job-queue/handlers/email.ts b/server/lib/job-queue/handlers/email.ts new file mode 100644 index 000000000..9d7686116 --- /dev/null +++ b/server/lib/job-queue/handlers/email.ts @@ -0,0 +1,22 @@ +import * as kue from 'kue' +import { logger } from '../../../helpers/logger' +import { Emailer } from '../../emailer' + +export type EmailPayload = { + to: string[] + subject: string + text: string +} + +async function processEmail (job: kue.Job) { + const payload = job.data as EmailPayload + logger.info('Processing email in job %d.', job.id) + + return Emailer.Instance.sendMail(payload.to, payload.subject, payload.text) +} + +// --------------------------------------------------------------------------- + +export { + processEmail +} diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts index 7a2b6c78d..3f176f896 100644 --- a/server/lib/job-queue/job-queue.ts +++ b/server/lib/job-queue/job-queue.ts @@ -5,19 +5,22 @@ import { CONFIG, JOB_ATTEMPTS, JOB_COMPLETED_LIFETIME, JOB_CONCURRENCY } from '. import { ActivitypubHttpBroadcastPayload, processActivityPubHttpBroadcast } from './handlers/activitypub-http-broadcast' import { ActivitypubHttpFetcherPayload, processActivityPubHttpFetcher } from './handlers/activitypub-http-fetcher' import { ActivitypubHttpUnicastPayload, processActivityPubHttpUnicast } from './handlers/activitypub-http-unicast' +import { EmailPayload, processEmail } from './handlers/email' import { processVideoFile, VideoFilePayload } from './handlers/video-file' type CreateJobArgument = { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | { type: 'activitypub-http-unicast', payload: ActivitypubHttpUnicastPayload } | { type: 'activitypub-http-fetcher', payload: ActivitypubHttpFetcherPayload } | - { type: 'video-file', payload: VideoFilePayload } + { type: 'video-file', payload: VideoFilePayload } | + { type: 'email', payload: EmailPayload } const handlers: { [ id in JobType ]: (job: kue.Job) => Promise} = { 'activitypub-http-broadcast': processActivityPubHttpBroadcast, 'activitypub-http-unicast': processActivityPubHttpUnicast, 'activitypub-http-fetcher': processActivityPubHttpFetcher, - 'video-file': processVideoFile + 'video-file': processVideoFile, + 'email': processEmail } class JobQueue { @@ -43,6 +46,8 @@ class JobQueue { } }) + this.jobQueue.setMaxListeners(15) + this.jobQueue.on('error', err => { logger.error('Error in job queue.', err) process.exit(-1) diff --git a/server/lib/redis.ts b/server/lib/redis.ts new file mode 100644 index 000000000..4240cc162 --- /dev/null +++ b/server/lib/redis.ts @@ -0,0 +1,84 @@ +import { createClient, RedisClient } from 'redis' +import { logger } from '../helpers/logger' +import { generateRandomString } from '../helpers/utils' +import { CONFIG, USER_PASSWORD_RESET_LIFETIME } from '../initializers' + +class Redis { + + private static instance: Redis + private initialized = false + private client: RedisClient + private prefix: string + + private constructor () {} + + init () { + // Already initialized + if (this.initialized === true) return + this.initialized = true + + this.client = createClient({ + host: CONFIG.REDIS.HOSTNAME, + port: CONFIG.REDIS.PORT + }) + + this.client.on('error', err => { + logger.error('Error in Redis client.', err) + process.exit(-1) + }) + + if (CONFIG.REDIS.AUTH) { + this.client.auth(CONFIG.REDIS.AUTH) + } + + this.prefix = 'redis-' + CONFIG.WEBSERVER.HOST + '-' + } + + async setResetPasswordVerificationString (userId: number) { + const generatedString = await generateRandomString(32) + + await this.setValue(this.generateResetPasswordKey(userId), generatedString, USER_PASSWORD_RESET_LIFETIME) + + return generatedString + } + + async getResetPasswordLink (userId: number) { + return this.getValue(this.generateResetPasswordKey(userId)) + } + + private getValue (key: string) { + return new Promise((res, rej) => { + this.client.get(this.prefix + key, (err, value) => { + if (err) return rej(err) + + return res(value) + }) + }) + } + + private setValue (key: string, value: string, expirationMilliseconds: number) { + return new Promise((res, rej) => { + this.client.set(this.prefix + key, value, 'PX', expirationMilliseconds, (err, ok) => { + if (err) return rej(err) + + if (ok !== 'OK') return rej(new Error('Redis result is not OK.')) + + return res() + }) + }) + } + + private generateResetPasswordKey (userId: number) { + return 'reset-password-' + userId + } + + static get Instance () { + return this.instance || (this.instance = new this()) + } +} + +// --------------------------------------------------------------------------- + +export { + Redis +} diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts index b6591c9e1..5f44c3b99 100644 --- a/server/middlewares/validators/users.ts +++ b/server/middlewares/validators/users.ts @@ -1,18 +1,25 @@ +import * as Bluebird from 'bluebird' import * as express from 'express' import 'express-validator' import { body, param } from 'express-validator/check' +import { omit } from 'lodash' import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' import { - isAvatarFile, isUserAutoPlayVideoValid, isUserDisplayNSFWValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid, + isAvatarFile, + isUserAutoPlayVideoValid, + isUserDisplayNSFWValid, + isUserPasswordValid, + isUserRoleValid, + isUserUsernameValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users' import { isVideoExist } from '../../helpers/custom-validators/videos' import { logger } from '../../helpers/logger' import { isSignupAllowed } from '../../helpers/utils' import { CONSTRAINTS_FIELDS } from '../../initializers' +import { Redis } from '../../lib/redis' import { UserModel } from '../../models/account/user' import { areValidationErrors } from './utils' -import { omit } from 'lodash' const usersAddValidator = [ body('username').custom(isUserUsernameValid).withMessage('Should have a valid username (lowercase alphanumeric characters)'), @@ -167,6 +174,49 @@ const ensureUserRegistrationAllowed = [ } ] +const usersAskResetPasswordValidator = [ + body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking usersAskResetPassword parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + const exists = await checkUserEmailExist(req.body.email, res, false) + if (!exists) { + logger.debug('User with email %s does not exist (asking reset password).', req.body.email) + // Do not leak our emails + return res.status(204).end() + } + + return next() + } +] + +const usersResetPasswordValidator = [ + param('id').isInt().not().isEmpty().withMessage('Should have a valid id'), + body('verificationString').not().isEmpty().withMessage('Should have a valid verification string'), + body('password').custom(isUserPasswordValid).withMessage('Should have a valid password'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking usersResetPassword parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await checkUserIdExist(req.params.id, res)) return + + const user = res.locals.user as UserModel + const redisVerificationString = await Redis.Instance.getResetPasswordLink(user.id) + + if (redisVerificationString !== req.body.verificationString) { + return res + .status(403) + .send({ error: 'Invalid verification string.' }) + .end + } + + return next() + } +] + // --------------------------------------------------------------------------- export { @@ -178,24 +228,19 @@ export { usersVideoRatingValidator, ensureUserRegistrationAllowed, usersGetValidator, - usersUpdateMyAvatarValidator + usersUpdateMyAvatarValidator, + usersAskResetPasswordValidator, + usersResetPasswordValidator } // --------------------------------------------------------------------------- -async function checkUserIdExist (id: number, res: express.Response) { - const user = await UserModel.loadById(id) - - if (!user) { - res.status(404) - .send({ error: 'User not found' }) - .end() - - return false - } +function checkUserIdExist (id: number, res: express.Response) { + return checkUserExist(() => UserModel.loadById(id), res) +} - res.locals.user = user - return true +function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) { + return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse) } async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) { @@ -210,3 +255,21 @@ async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: return true } + +async function checkUserExist (finder: () => Bluebird, res: express.Response, abortResponse = true) { + const user = await finder() + + if (!user) { + if (abortResponse === true) { + res.status(404) + .send({ error: 'User not found' }) + .end() + } + + return false + } + + res.locals.user = user + + return true +} diff --git a/server/models/account/user.ts b/server/models/account/user.ts index 809e821bd..026a8c9a0 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts @@ -161,6 +161,16 @@ export class UserModel extends Model { return UserModel.scope('withVideoChannel').findOne(query) } + static loadByEmail (email: string) { + const query = { + where: { + email + } + } + + return UserModel.findOne(query) + } + static loadByUsernameOrEmail (username: string, email?: string) { if (!email) email = username -- cgit v1.2.3