diff options
author | Chocobozzz <me@florianbigard.com> | 2018-01-30 13:27:07 +0100 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2018-01-30 13:27:07 +0100 |
commit | ecb4e35f4e6c7304cb274593c13cb47fd5078b75 (patch) | |
tree | 1e238002340bc521afde59d52f406e41298a7aac /server | |
parent | 80d1057bfcd3582af0dacf5ccd5a7a93ef95410b (diff) | |
download | PeerTube-ecb4e35f4e6c7304cb274593c13cb47fd5078b75.tar.gz PeerTube-ecb4e35f4e6c7304cb274593c13cb47fd5078b75.tar.zst PeerTube-ecb4e35f4e6c7304cb274593c13cb47fd5078b75.zip |
Add ability to reset our password
Diffstat (limited to 'server')
-rw-r--r-- | server/controllers/api/users.ts | 39 | ||||
-rw-r--r-- | server/helpers/logger.ts | 1 | ||||
-rw-r--r-- | server/initializers/checker.ts | 3 | ||||
-rw-r--r-- | server/initializers/constants.ts | 20 | ||||
-rw-r--r-- | server/lib/emailer.ts | 106 | ||||
-rw-r--r-- | server/lib/job-queue/handlers/email.ts | 22 | ||||
-rw-r--r-- | server/lib/job-queue/job-queue.ts | 9 | ||||
-rw-r--r-- | server/lib/redis.ts | 84 | ||||
-rw-r--r-- | server/middlewares/validators/users.ts | 93 | ||||
-rw-r--r-- | server/models/account/user.ts | 10 |
10 files changed, 364 insertions, 23 deletions
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 | |||
6 | import { unlinkPromise } from '../../helpers/core-utils' | 6 | import { unlinkPromise } from '../../helpers/core-utils' |
7 | import { retryTransactionWrapper } from '../../helpers/database-utils' | 7 | import { retryTransactionWrapper } from '../../helpers/database-utils' |
8 | import { logger } from '../../helpers/logger' | 8 | import { logger } from '../../helpers/logger' |
9 | import { createReqFiles, getFormattedObjects } from '../../helpers/utils' | 9 | import { createReqFiles, generateRandomString, getFormattedObjects } from '../../helpers/utils' |
10 | import { AVATAR_MIMETYPE_EXT, AVATARS_SIZE, CONFIG, sequelizeTypescript } from '../../initializers' | 10 | import { AVATAR_MIMETYPE_EXT, AVATARS_SIZE, CONFIG, sequelizeTypescript } from '../../initializers' |
11 | import { updateActorAvatarInstance } from '../../lib/activitypub' | 11 | import { updateActorAvatarInstance } from '../../lib/activitypub' |
12 | import { sendUpdateUser } from '../../lib/activitypub/send' | 12 | import { sendUpdateUser } from '../../lib/activitypub/send' |
13 | import { Emailer } from '../../lib/emailer' | ||
14 | import { EmailPayload } from '../../lib/job-queue/handlers/email' | ||
15 | import { Redis } from '../../lib/redis' | ||
13 | import { createUserAccountAndChannel } from '../../lib/user' | 16 | import { createUserAccountAndChannel } from '../../lib/user' |
14 | import { | 17 | import { |
15 | asyncMiddleware, authenticate, ensureUserHasRight, ensureUserRegistrationAllowed, paginationValidator, setDefaultSort, | 18 | asyncMiddleware, authenticate, ensureUserHasRight, ensureUserRegistrationAllowed, paginationValidator, setDefaultSort, |
16 | setDefaultPagination, token, usersAddValidator, usersGetValidator, usersRegisterValidator, usersRemoveValidator, usersSortValidator, | 19 | setDefaultPagination, token, usersAddValidator, usersGetValidator, usersRegisterValidator, usersRemoveValidator, usersSortValidator, |
17 | usersUpdateMeValidator, usersUpdateValidator, usersVideoRatingValidator | 20 | usersUpdateMeValidator, usersUpdateValidator, usersVideoRatingValidator |
18 | } from '../../middlewares' | 21 | } from '../../middlewares' |
19 | import { usersUpdateMyAvatarValidator, videosSortValidator } from '../../middlewares/validators' | 22 | import { |
23 | usersAskResetPasswordValidator, usersResetPasswordValidator, usersUpdateMyAvatarValidator, | ||
24 | videosSortValidator | ||
25 | } from '../../middlewares/validators' | ||
20 | import { AccountVideoRateModel } from '../../models/account/account-video-rate' | 26 | import { AccountVideoRateModel } from '../../models/account/account-video-rate' |
21 | import { UserModel } from '../../models/account/user' | 27 | import { UserModel } from '../../models/account/user' |
22 | import { OAuthTokenModel } from '../../models/oauth/oauth-token' | 28 | import { OAuthTokenModel } from '../../models/oauth/oauth-token' |
@@ -106,6 +112,16 @@ usersRouter.delete('/:id', | |||
106 | asyncMiddleware(removeUser) | 112 | asyncMiddleware(removeUser) |
107 | ) | 113 | ) |
108 | 114 | ||
115 | usersRouter.post('/ask-reset-password', | ||
116 | asyncMiddleware(usersAskResetPasswordValidator), | ||
117 | asyncMiddleware(askResetUserPassword) | ||
118 | ) | ||
119 | |||
120 | usersRouter.post('/:id/reset-password', | ||
121 | asyncMiddleware(usersResetPasswordValidator), | ||
122 | asyncMiddleware(resetUserPassword) | ||
123 | ) | ||
124 | |||
109 | usersRouter.post('/token', token, success) | 125 | usersRouter.post('/token', token, success) |
110 | // TODO: Once https://github.com/oauthjs/node-oauth2-server/pull/289 is merged, implement revoke token route | 126 | // TODO: Once https://github.com/oauthjs/node-oauth2-server/pull/289 is merged, implement revoke token route |
111 | 127 | ||
@@ -307,6 +323,25 @@ async function updateUser (req: express.Request, res: express.Response, next: ex | |||
307 | return res.sendStatus(204) | 323 | return res.sendStatus(204) |
308 | } | 324 | } |
309 | 325 | ||
326 | async function askResetUserPassword (req: express.Request, res: express.Response, next: express.NextFunction) { | ||
327 | const user = res.locals.user as UserModel | ||
328 | |||
329 | const verificationString = await Redis.Instance.setResetPasswordVerificationString(user.id) | ||
330 | const url = CONFIG.WEBSERVER.URL + '/reset-password?userId=' + user.id + '&verificationString=' + verificationString | ||
331 | await Emailer.Instance.addForgetPasswordEmailJob(user.email, url) | ||
332 | |||
333 | return res.status(204).end() | ||
334 | } | ||
335 | |||
336 | async function resetUserPassword (req: express.Request, res: express.Response, next: express.NextFunction) { | ||
337 | const user = res.locals.user as UserModel | ||
338 | user.password = req.body.password | ||
339 | |||
340 | await user.save() | ||
341 | |||
342 | return res.status(204).end() | ||
343 | } | ||
344 | |||
310 | function success (req: express.Request, res: express.Response, next: express.NextFunction) { | 345 | function success (req: express.Request, res: express.Response, next: express.NextFunction) { |
311 | res.end() | 346 | res.end() |
312 | } | 347 | } |
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) => { | |||
26 | if (additionalInfos === '{}') additionalInfos = '' | 26 | if (additionalInfos === '{}') additionalInfos = '' |
27 | else additionalInfos = ' ' + additionalInfos | 27 | else additionalInfos = ' ' + additionalInfos |
28 | 28 | ||
29 | if (info.message.stack !== undefined) info.message = info.message.stack | ||
29 | return `[${info.label}] ${info.timestamp} ${info.level}: ${info.message}${additionalInfos}` | 30 | return `[${info.label}] ${info.timestamp} ${info.level}: ${info.message}${additionalInfos}` |
30 | }) | 31 | }) |
31 | 32 | ||
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 () { | |||
22 | 'webserver.https', 'webserver.hostname', 'webserver.port', | 22 | 'webserver.https', 'webserver.hostname', 'webserver.port', |
23 | 'database.hostname', 'database.port', 'database.suffix', 'database.username', 'database.password', | 23 | 'database.hostname', 'database.port', 'database.suffix', 'database.username', 'database.password', |
24 | 'storage.videos', 'storage.logs', 'storage.thumbnails', 'storage.previews', 'storage.torrents', 'storage.cache', 'log.level', | 24 | 'storage.videos', 'storage.logs', 'storage.thumbnails', 'storage.previews', 'storage.torrents', 'storage.cache', 'log.level', |
25 | 'cache.previews.size', 'admin.email', 'signup.enabled', 'signup.limit', 'transcoding.enabled', 'transcoding.threads', 'user.video_quota' | 25 | 'cache.previews.size', 'admin.email', 'signup.enabled', 'signup.limit', 'transcoding.enabled', 'transcoding.threads', |
26 | 'user.video_quota', 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address' | ||
26 | ] | 27 | ] |
27 | const miss: string[] = [] | 28 | const miss: string[] = [] |
28 | 29 | ||
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 } = { | |||
65 | 'activitypub-http-broadcast': 5, | 65 | 'activitypub-http-broadcast': 5, |
66 | 'activitypub-http-unicast': 5, | 66 | 'activitypub-http-unicast': 5, |
67 | 'activitypub-http-fetcher': 5, | 67 | 'activitypub-http-fetcher': 5, |
68 | 'video-file': 1 | 68 | 'video-file': 1, |
69 | 'email': 5 | ||
69 | } | 70 | } |
70 | const JOB_CONCURRENCY: { [ id in JobType ]: number } = { | 71 | const JOB_CONCURRENCY: { [ id in JobType ]: number } = { |
71 | 'activitypub-http-broadcast': 1, | 72 | 'activitypub-http-broadcast': 1, |
72 | 'activitypub-http-unicast': 5, | 73 | 'activitypub-http-unicast': 5, |
73 | 'activitypub-http-fetcher': 1, | 74 | 'activitypub-http-fetcher': 1, |
74 | 'video-file': 1 | 75 | 'video-file': 1, |
76 | 'email': 5 | ||
75 | } | 77 | } |
76 | // 2 days | 78 | // 2 days |
77 | const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2 | 79 | const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2 |
@@ -95,9 +97,18 @@ const CONFIG = { | |||
95 | }, | 97 | }, |
96 | REDIS: { | 98 | REDIS: { |
97 | HOSTNAME: config.get<string>('redis.hostname'), | 99 | HOSTNAME: config.get<string>('redis.hostname'), |
98 | PORT: config.get<string>('redis.port'), | 100 | PORT: config.get<number>('redis.port'), |
99 | AUTH: config.get<string>('redis.auth') | 101 | AUTH: config.get<string>('redis.auth') |
100 | }, | 102 | }, |
103 | SMTP: { | ||
104 | HOSTNAME: config.get<string>('smtp.hostname'), | ||
105 | PORT: config.get<number>('smtp.port'), | ||
106 | USERNAME: config.get<string>('smtp.username'), | ||
107 | PASSWORD: config.get<string>('smtp.password'), | ||
108 | TLS: config.get<boolean>('smtp.tls'), | ||
109 | CA_FILE: config.get<string>('smtp.ca_file'), | ||
110 | FROM_ADDRESS: config.get<string>('smtp.from_address') | ||
111 | }, | ||
101 | STORAGE: { | 112 | STORAGE: { |
102 | AVATARS_DIR: buildPath(config.get<string>('storage.avatars')), | 113 | AVATARS_DIR: buildPath(config.get<string>('storage.avatars')), |
103 | LOG_DIR: buildPath(config.get<string>('storage.logs')), | 114 | LOG_DIR: buildPath(config.get<string>('storage.logs')), |
@@ -311,6 +322,8 @@ const PRIVATE_RSA_KEY_SIZE = 2048 | |||
311 | // Password encryption | 322 | // Password encryption |
312 | const BCRYPT_SALT_SIZE = 10 | 323 | const BCRYPT_SALT_SIZE = 10 |
313 | 324 | ||
325 | const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes | ||
326 | |||
314 | // --------------------------------------------------------------------------- | 327 | // --------------------------------------------------------------------------- |
315 | 328 | ||
316 | // Express static paths (router) | 329 | // Express static paths (router) |
@@ -408,6 +421,7 @@ export { | |||
408 | VIDEO_LICENCES, | 421 | VIDEO_LICENCES, |
409 | VIDEO_RATE_TYPES, | 422 | VIDEO_RATE_TYPES, |
410 | VIDEO_MIMETYPE_EXT, | 423 | VIDEO_MIMETYPE_EXT, |
424 | USER_PASSWORD_RESET_LIFETIME, | ||
411 | AVATAR_MIMETYPE_EXT, | 425 | AVATAR_MIMETYPE_EXT, |
412 | SCHEDULER_INTERVAL, | 426 | SCHEDULER_INTERVAL, |
413 | JOB_COMPLETED_LIFETIME | 427 | 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 @@ | |||
1 | import { createTransport, Transporter } from 'nodemailer' | ||
2 | import { isTestInstance } from '../helpers/core-utils' | ||
3 | import { logger } from '../helpers/logger' | ||
4 | import { CONFIG } from '../initializers' | ||
5 | import { JobQueue } from './job-queue' | ||
6 | import { EmailPayload } from './job-queue/handlers/email' | ||
7 | import { readFileSync } from 'fs' | ||
8 | |||
9 | class Emailer { | ||
10 | |||
11 | private static instance: Emailer | ||
12 | private initialized = false | ||
13 | private transporter: Transporter | ||
14 | |||
15 | private constructor () {} | ||
16 | |||
17 | init () { | ||
18 | // Already initialized | ||
19 | if (this.initialized === true) return | ||
20 | this.initialized = true | ||
21 | |||
22 | if (CONFIG.SMTP.HOSTNAME && CONFIG.SMTP.PORT) { | ||
23 | logger.info('Using %s:%s as SMTP server.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT) | ||
24 | |||
25 | let tls | ||
26 | if (CONFIG.SMTP.CA_FILE) { | ||
27 | tls = { | ||
28 | ca: [ readFileSync(CONFIG.SMTP.CA_FILE) ] | ||
29 | } | ||
30 | } | ||
31 | |||
32 | this.transporter = createTransport({ | ||
33 | host: CONFIG.SMTP.HOSTNAME, | ||
34 | port: CONFIG.SMTP.PORT, | ||
35 | secure: CONFIG.SMTP.TLS, | ||
36 | tls, | ||
37 | auth: { | ||
38 | user: CONFIG.SMTP.USERNAME, | ||
39 | pass: CONFIG.SMTP.PASSWORD | ||
40 | } | ||
41 | }) | ||
42 | } else { | ||
43 | if (!isTestInstance()) { | ||
44 | logger.error('Cannot use SMTP server because of lack of configuration. PeerTube will not be able to send mails!') | ||
45 | } | ||
46 | } | ||
47 | } | ||
48 | |||
49 | async checkConnectionOrDie () { | ||
50 | if (!this.transporter) return | ||
51 | |||
52 | try { | ||
53 | const success = await this.transporter.verify() | ||
54 | if (success !== true) this.dieOnConnectionFailure() | ||
55 | |||
56 | logger.info('Successfully connected to SMTP server.') | ||
57 | } catch (err) { | ||
58 | this.dieOnConnectionFailure(err) | ||
59 | } | ||
60 | } | ||
61 | |||
62 | addForgetPasswordEmailJob (to: string, resetPasswordUrl: string) { | ||
63 | const text = `Hi dear user,\n\n` + | ||
64 | `It seems you forgot your password on ${CONFIG.WEBSERVER.HOST}! ` + | ||
65 | `Please follow this link to reset it: ${resetPasswordUrl}.\n\n` + | ||
66 | `If you are not the person who initiated this request, please ignore this email.\n\n` + | ||
67 | `Cheers,\n` + | ||
68 | `PeerTube.` | ||
69 | |||
70 | const emailPayload: EmailPayload = { | ||
71 | to: [ to ], | ||
72 | subject: 'Reset your PeerTube password', | ||
73 | text | ||
74 | } | ||
75 | |||
76 | return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) | ||
77 | } | ||
78 | |||
79 | sendMail (to: string[], subject: string, text: string) { | ||
80 | if (!this.transporter) { | ||
81 | throw new Error('Cannot send mail because SMTP is not configured.') | ||
82 | } | ||
83 | |||
84 | return this.transporter.sendMail({ | ||
85 | from: CONFIG.SMTP.FROM_ADDRESS, | ||
86 | to: to.join(','), | ||
87 | subject, | ||
88 | text | ||
89 | }) | ||
90 | } | ||
91 | |||
92 | private dieOnConnectionFailure (err?: Error) { | ||
93 | logger.error('Failed to connect to SMTP %s:%d.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT, err) | ||
94 | process.exit(-1) | ||
95 | } | ||
96 | |||
97 | static get Instance () { | ||
98 | return this.instance || (this.instance = new this()) | ||
99 | } | ||
100 | } | ||
101 | |||
102 | // --------------------------------------------------------------------------- | ||
103 | |||
104 | export { | ||
105 | Emailer | ||
106 | } | ||
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 @@ | |||
1 | import * as kue from 'kue' | ||
2 | import { logger } from '../../../helpers/logger' | ||
3 | import { Emailer } from '../../emailer' | ||
4 | |||
5 | export type EmailPayload = { | ||
6 | to: string[] | ||
7 | subject: string | ||
8 | text: string | ||
9 | } | ||
10 | |||
11 | async function processEmail (job: kue.Job) { | ||
12 | const payload = job.data as EmailPayload | ||
13 | logger.info('Processing email in job %d.', job.id) | ||
14 | |||
15 | return Emailer.Instance.sendMail(payload.to, payload.subject, payload.text) | ||
16 | } | ||
17 | |||
18 | // --------------------------------------------------------------------------- | ||
19 | |||
20 | export { | ||
21 | processEmail | ||
22 | } | ||
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 '. | |||
5 | import { ActivitypubHttpBroadcastPayload, processActivityPubHttpBroadcast } from './handlers/activitypub-http-broadcast' | 5 | import { ActivitypubHttpBroadcastPayload, processActivityPubHttpBroadcast } from './handlers/activitypub-http-broadcast' |
6 | import { ActivitypubHttpFetcherPayload, processActivityPubHttpFetcher } from './handlers/activitypub-http-fetcher' | 6 | import { ActivitypubHttpFetcherPayload, processActivityPubHttpFetcher } from './handlers/activitypub-http-fetcher' |
7 | import { ActivitypubHttpUnicastPayload, processActivityPubHttpUnicast } from './handlers/activitypub-http-unicast' | 7 | import { ActivitypubHttpUnicastPayload, processActivityPubHttpUnicast } from './handlers/activitypub-http-unicast' |
8 | import { EmailPayload, processEmail } from './handlers/email' | ||
8 | import { processVideoFile, VideoFilePayload } from './handlers/video-file' | 9 | import { processVideoFile, VideoFilePayload } from './handlers/video-file' |
9 | 10 | ||
10 | type CreateJobArgument = | 11 | type CreateJobArgument = |
11 | { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | | 12 | { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | |
12 | { type: 'activitypub-http-unicast', payload: ActivitypubHttpUnicastPayload } | | 13 | { type: 'activitypub-http-unicast', payload: ActivitypubHttpUnicastPayload } | |
13 | { type: 'activitypub-http-fetcher', payload: ActivitypubHttpFetcherPayload } | | 14 | { type: 'activitypub-http-fetcher', payload: ActivitypubHttpFetcherPayload } | |
14 | { type: 'video-file', payload: VideoFilePayload } | 15 | { type: 'video-file', payload: VideoFilePayload } | |
16 | { type: 'email', payload: EmailPayload } | ||
15 | 17 | ||
16 | const handlers: { [ id in JobType ]: (job: kue.Job) => Promise<any>} = { | 18 | const handlers: { [ id in JobType ]: (job: kue.Job) => Promise<any>} = { |
17 | 'activitypub-http-broadcast': processActivityPubHttpBroadcast, | 19 | 'activitypub-http-broadcast': processActivityPubHttpBroadcast, |
18 | 'activitypub-http-unicast': processActivityPubHttpUnicast, | 20 | 'activitypub-http-unicast': processActivityPubHttpUnicast, |
19 | 'activitypub-http-fetcher': processActivityPubHttpFetcher, | 21 | 'activitypub-http-fetcher': processActivityPubHttpFetcher, |
20 | 'video-file': processVideoFile | 22 | 'video-file': processVideoFile, |
23 | 'email': processEmail | ||
21 | } | 24 | } |
22 | 25 | ||
23 | class JobQueue { | 26 | class JobQueue { |
@@ -43,6 +46,8 @@ class JobQueue { | |||
43 | } | 46 | } |
44 | }) | 47 | }) |
45 | 48 | ||
49 | this.jobQueue.setMaxListeners(15) | ||
50 | |||
46 | this.jobQueue.on('error', err => { | 51 | this.jobQueue.on('error', err => { |
47 | logger.error('Error in job queue.', err) | 52 | logger.error('Error in job queue.', err) |
48 | process.exit(-1) | 53 | 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 @@ | |||
1 | import { createClient, RedisClient } from 'redis' | ||
2 | import { logger } from '../helpers/logger' | ||
3 | import { generateRandomString } from '../helpers/utils' | ||
4 | import { CONFIG, USER_PASSWORD_RESET_LIFETIME } from '../initializers' | ||
5 | |||
6 | class Redis { | ||
7 | |||
8 | private static instance: Redis | ||
9 | private initialized = false | ||
10 | private client: RedisClient | ||
11 | private prefix: string | ||
12 | |||
13 | private constructor () {} | ||
14 | |||
15 | init () { | ||
16 | // Already initialized | ||
17 | if (this.initialized === true) return | ||
18 | this.initialized = true | ||
19 | |||
20 | this.client = createClient({ | ||
21 | host: CONFIG.REDIS.HOSTNAME, | ||
22 | port: CONFIG.REDIS.PORT | ||
23 | }) | ||
24 | |||
25 | this.client.on('error', err => { | ||
26 | logger.error('Error in Redis client.', err) | ||
27 | process.exit(-1) | ||
28 | }) | ||
29 | |||
30 | if (CONFIG.REDIS.AUTH) { | ||
31 | this.client.auth(CONFIG.REDIS.AUTH) | ||
32 | } | ||
33 | |||
34 | this.prefix = 'redis-' + CONFIG.WEBSERVER.HOST + '-' | ||
35 | } | ||
36 | |||
37 | async setResetPasswordVerificationString (userId: number) { | ||
38 | const generatedString = await generateRandomString(32) | ||
39 | |||
40 | await this.setValue(this.generateResetPasswordKey(userId), generatedString, USER_PASSWORD_RESET_LIFETIME) | ||
41 | |||
42 | return generatedString | ||
43 | } | ||
44 | |||
45 | async getResetPasswordLink (userId: number) { | ||
46 | return this.getValue(this.generateResetPasswordKey(userId)) | ||
47 | } | ||
48 | |||
49 | private getValue (key: string) { | ||
50 | return new Promise<string>((res, rej) => { | ||
51 | this.client.get(this.prefix + key, (err, value) => { | ||
52 | if (err) return rej(err) | ||
53 | |||
54 | return res(value) | ||
55 | }) | ||
56 | }) | ||
57 | } | ||
58 | |||
59 | private setValue (key: string, value: string, expirationMilliseconds: number) { | ||
60 | return new Promise<void>((res, rej) => { | ||
61 | this.client.set(this.prefix + key, value, 'PX', expirationMilliseconds, (err, ok) => { | ||
62 | if (err) return rej(err) | ||
63 | |||
64 | if (ok !== 'OK') return rej(new Error('Redis result is not OK.')) | ||
65 | |||
66 | return res() | ||
67 | }) | ||
68 | }) | ||
69 | } | ||
70 | |||
71 | private generateResetPasswordKey (userId: number) { | ||
72 | return 'reset-password-' + userId | ||
73 | } | ||
74 | |||
75 | static get Instance () { | ||
76 | return this.instance || (this.instance = new this()) | ||
77 | } | ||
78 | } | ||
79 | |||
80 | // --------------------------------------------------------------------------- | ||
81 | |||
82 | export { | ||
83 | Redis | ||
84 | } | ||
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 @@ | |||
1 | import * as Bluebird from 'bluebird' | ||
1 | import * as express from 'express' | 2 | import * as express from 'express' |
2 | import 'express-validator' | 3 | import 'express-validator' |
3 | import { body, param } from 'express-validator/check' | 4 | import { body, param } from 'express-validator/check' |
5 | import { omit } from 'lodash' | ||
4 | import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' | 6 | import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' |
5 | import { | 7 | import { |
6 | isAvatarFile, isUserAutoPlayVideoValid, isUserDisplayNSFWValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid, | 8 | isAvatarFile, |
9 | isUserAutoPlayVideoValid, | ||
10 | isUserDisplayNSFWValid, | ||
11 | isUserPasswordValid, | ||
12 | isUserRoleValid, | ||
13 | isUserUsernameValid, | ||
7 | isUserVideoQuotaValid | 14 | isUserVideoQuotaValid |
8 | } from '../../helpers/custom-validators/users' | 15 | } from '../../helpers/custom-validators/users' |
9 | import { isVideoExist } from '../../helpers/custom-validators/videos' | 16 | import { isVideoExist } from '../../helpers/custom-validators/videos' |
10 | import { logger } from '../../helpers/logger' | 17 | import { logger } from '../../helpers/logger' |
11 | import { isSignupAllowed } from '../../helpers/utils' | 18 | import { isSignupAllowed } from '../../helpers/utils' |
12 | import { CONSTRAINTS_FIELDS } from '../../initializers' | 19 | import { CONSTRAINTS_FIELDS } from '../../initializers' |
20 | import { Redis } from '../../lib/redis' | ||
13 | import { UserModel } from '../../models/account/user' | 21 | import { UserModel } from '../../models/account/user' |
14 | import { areValidationErrors } from './utils' | 22 | import { areValidationErrors } from './utils' |
15 | import { omit } from 'lodash' | ||
16 | 23 | ||
17 | const usersAddValidator = [ | 24 | const usersAddValidator = [ |
18 | body('username').custom(isUserUsernameValid).withMessage('Should have a valid username (lowercase alphanumeric characters)'), | 25 | body('username').custom(isUserUsernameValid).withMessage('Should have a valid username (lowercase alphanumeric characters)'), |
@@ -167,6 +174,49 @@ const ensureUserRegistrationAllowed = [ | |||
167 | } | 174 | } |
168 | ] | 175 | ] |
169 | 176 | ||
177 | const usersAskResetPasswordValidator = [ | ||
178 | body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'), | ||
179 | |||
180 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
181 | logger.debug('Checking usersAskResetPassword parameters', { parameters: req.body }) | ||
182 | |||
183 | if (areValidationErrors(req, res)) return | ||
184 | const exists = await checkUserEmailExist(req.body.email, res, false) | ||
185 | if (!exists) { | ||
186 | logger.debug('User with email %s does not exist (asking reset password).', req.body.email) | ||
187 | // Do not leak our emails | ||
188 | return res.status(204).end() | ||
189 | } | ||
190 | |||
191 | return next() | ||
192 | } | ||
193 | ] | ||
194 | |||
195 | const usersResetPasswordValidator = [ | ||
196 | param('id').isInt().not().isEmpty().withMessage('Should have a valid id'), | ||
197 | body('verificationString').not().isEmpty().withMessage('Should have a valid verification string'), | ||
198 | body('password').custom(isUserPasswordValid).withMessage('Should have a valid password'), | ||
199 | |||
200 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
201 | logger.debug('Checking usersResetPassword parameters', { parameters: req.params }) | ||
202 | |||
203 | if (areValidationErrors(req, res)) return | ||
204 | if (!await checkUserIdExist(req.params.id, res)) return | ||
205 | |||
206 | const user = res.locals.user as UserModel | ||
207 | const redisVerificationString = await Redis.Instance.getResetPasswordLink(user.id) | ||
208 | |||
209 | if (redisVerificationString !== req.body.verificationString) { | ||
210 | return res | ||
211 | .status(403) | ||
212 | .send({ error: 'Invalid verification string.' }) | ||
213 | .end | ||
214 | } | ||
215 | |||
216 | return next() | ||
217 | } | ||
218 | ] | ||
219 | |||
170 | // --------------------------------------------------------------------------- | 220 | // --------------------------------------------------------------------------- |
171 | 221 | ||
172 | export { | 222 | export { |
@@ -178,24 +228,19 @@ export { | |||
178 | usersVideoRatingValidator, | 228 | usersVideoRatingValidator, |
179 | ensureUserRegistrationAllowed, | 229 | ensureUserRegistrationAllowed, |
180 | usersGetValidator, | 230 | usersGetValidator, |
181 | usersUpdateMyAvatarValidator | 231 | usersUpdateMyAvatarValidator, |
232 | usersAskResetPasswordValidator, | ||
233 | usersResetPasswordValidator | ||
182 | } | 234 | } |
183 | 235 | ||
184 | // --------------------------------------------------------------------------- | 236 | // --------------------------------------------------------------------------- |
185 | 237 | ||
186 | async function checkUserIdExist (id: number, res: express.Response) { | 238 | function checkUserIdExist (id: number, res: express.Response) { |
187 | const user = await UserModel.loadById(id) | 239 | return checkUserExist(() => UserModel.loadById(id), res) |
188 | 240 | } | |
189 | if (!user) { | ||
190 | res.status(404) | ||
191 | .send({ error: 'User not found' }) | ||
192 | .end() | ||
193 | |||
194 | return false | ||
195 | } | ||
196 | 241 | ||
197 | res.locals.user = user | 242 | function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) { |
198 | return true | 243 | return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse) |
199 | } | 244 | } |
200 | 245 | ||
201 | async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) { | 246 | async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) { |
@@ -210,3 +255,21 @@ async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: | |||
210 | 255 | ||
211 | return true | 256 | return true |
212 | } | 257 | } |
258 | |||
259 | async function checkUserExist (finder: () => Bluebird<UserModel>, res: express.Response, abortResponse = true) { | ||
260 | const user = await finder() | ||
261 | |||
262 | if (!user) { | ||
263 | if (abortResponse === true) { | ||
264 | res.status(404) | ||
265 | .send({ error: 'User not found' }) | ||
266 | .end() | ||
267 | } | ||
268 | |||
269 | return false | ||
270 | } | ||
271 | |||
272 | res.locals.user = user | ||
273 | |||
274 | return true | ||
275 | } | ||
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<UserModel> { | |||
161 | return UserModel.scope('withVideoChannel').findOne(query) | 161 | return UserModel.scope('withVideoChannel').findOne(query) |
162 | } | 162 | } |
163 | 163 | ||
164 | static loadByEmail (email: string) { | ||
165 | const query = { | ||
166 | where: { | ||
167 | |||
168 | } | ||
169 | } | ||
170 | |||
171 | return UserModel.findOne(query) | ||
172 | } | ||
173 | |||
164 | static loadByUsernameOrEmail (username: string, email?: string) { | 174 | static loadByUsernameOrEmail (username: string, email?: string) { |
165 | if (!email) email = username | 175 | if (!email) email = username |
166 | 176 | ||