diff options
Diffstat (limited to 'server')
22 files changed, 419 insertions, 15 deletions
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts index 25ddd1fa6..6edbe4820 100644 --- a/server/controllers/api/config.ts +++ b/server/controllers/api/config.ts | |||
@@ -60,7 +60,8 @@ async function getConfig (req: express.Request, res: express.Response, next: exp | |||
60 | serverVersion: packageJSON.version, | 60 | serverVersion: packageJSON.version, |
61 | signup: { | 61 | signup: { |
62 | allowed, | 62 | allowed, |
63 | allowedForCurrentIP | 63 | allowedForCurrentIP, |
64 | requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION | ||
64 | }, | 65 | }, |
65 | transcoding: { | 66 | transcoding: { |
66 | enabledResolutions | 67 | enabledResolutions |
@@ -159,12 +160,20 @@ async function updateCustomConfig (req: express.Request, res: express.Response, | |||
159 | toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10) | 160 | toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10) |
160 | 161 | ||
161 | // camelCase to snake_case key | 162 | // camelCase to snake_case key |
162 | const toUpdateJSON = omit(toUpdate, 'user.videoQuota', 'instance.defaultClientRoute', 'instance.shortDescription', 'cache.videoCaptions') | 163 | const toUpdateJSON = omit( |
164 | toUpdate, | ||
165 | 'user.videoQuota', | ||
166 | 'instance.defaultClientRoute', | ||
167 | 'instance.shortDescription', | ||
168 | 'cache.videoCaptions', | ||
169 | 'signup.requiresEmailVerification' | ||
170 | ) | ||
163 | toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota | 171 | toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota |
164 | toUpdateJSON.user['video_quota_daily'] = toUpdate.user.videoQuotaDaily | 172 | toUpdateJSON.user['video_quota_daily'] = toUpdate.user.videoQuotaDaily |
165 | toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute | 173 | toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute |
166 | toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription | 174 | toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription |
167 | toUpdateJSON.instance['default_nsfw_policy'] = toUpdate.instance.defaultNSFWPolicy | 175 | toUpdateJSON.instance['default_nsfw_policy'] = toUpdate.instance.defaultNSFWPolicy |
176 | toUpdateJSON.signup['requires_email_verification'] = toUpdate.signup.requiresEmailVerification | ||
168 | 177 | ||
169 | await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 }) | 178 | await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 }) |
170 | 179 | ||
@@ -220,7 +229,8 @@ function customConfig (): CustomConfig { | |||
220 | }, | 229 | }, |
221 | signup: { | 230 | signup: { |
222 | enabled: CONFIG.SIGNUP.ENABLED, | 231 | enabled: CONFIG.SIGNUP.ENABLED, |
223 | limit: CONFIG.SIGNUP.LIMIT | 232 | limit: CONFIG.SIGNUP.LIMIT, |
233 | requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION | ||
224 | }, | 234 | }, |
225 | admin: { | 235 | admin: { |
226 | email: CONFIG.ADMIN.EMAIL | 236 | email: CONFIG.ADMIN.EMAIL |
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts index 25d51ae5e..008c34ca4 100644 --- a/server/controllers/api/users/index.ts +++ b/server/controllers/api/users/index.ts | |||
@@ -25,7 +25,10 @@ import { | |||
25 | usersSortValidator, | 25 | usersSortValidator, |
26 | usersUpdateValidator | 26 | usersUpdateValidator |
27 | } from '../../../middlewares' | 27 | } from '../../../middlewares' |
28 | import { usersAskResetPasswordValidator, usersBlockingValidator, usersResetPasswordValidator } from '../../../middlewares/validators' | 28 | import { |
29 | usersAskResetPasswordValidator, usersBlockingValidator, usersResetPasswordValidator, | ||
30 | usersAskSendVerifyEmailValidator, usersVerifyEmailValidator | ||
31 | } from '../../../middlewares/validators' | ||
29 | import { UserModel } from '../../../models/account/user' | 32 | import { UserModel } from '../../../models/account/user' |
30 | import { OAuthTokenModel } from '../../../models/oauth/oauth-token' | 33 | import { OAuthTokenModel } from '../../../models/oauth/oauth-token' |
31 | import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger' | 34 | import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger' |
@@ -110,6 +113,17 @@ usersRouter.post('/:id/reset-password', | |||
110 | asyncMiddleware(resetUserPassword) | 113 | asyncMiddleware(resetUserPassword) |
111 | ) | 114 | ) |
112 | 115 | ||
116 | usersRouter.post('/ask-send-verify-email', | ||
117 | loginRateLimiter, | ||
118 | asyncMiddleware(usersAskSendVerifyEmailValidator), | ||
119 | asyncMiddleware(askSendVerifyUserEmail) | ||
120 | ) | ||
121 | |||
122 | usersRouter.post('/:id/verify-email', | ||
123 | asyncMiddleware(usersVerifyEmailValidator), | ||
124 | asyncMiddleware(verifyUserEmail) | ||
125 | ) | ||
126 | |||
113 | usersRouter.post('/token', | 127 | usersRouter.post('/token', |
114 | loginRateLimiter, | 128 | loginRateLimiter, |
115 | token, | 129 | token, |
@@ -165,7 +179,8 @@ async function registerUser (req: express.Request, res: express.Response) { | |||
165 | autoPlayVideo: true, | 179 | autoPlayVideo: true, |
166 | role: UserRole.USER, | 180 | role: UserRole.USER, |
167 | videoQuota: CONFIG.USER.VIDEO_QUOTA, | 181 | videoQuota: CONFIG.USER.VIDEO_QUOTA, |
168 | videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY | 182 | videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY, |
183 | emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null | ||
169 | }) | 184 | }) |
170 | 185 | ||
171 | const { user } = await createUserAccountAndChannel(userToCreate) | 186 | const { user } = await createUserAccountAndChannel(userToCreate) |
@@ -173,6 +188,10 @@ async function registerUser (req: express.Request, res: express.Response) { | |||
173 | auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON())) | 188 | auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON())) |
174 | logger.info('User %s with its channel and account registered.', body.username) | 189 | logger.info('User %s with its channel and account registered.', body.username) |
175 | 190 | ||
191 | if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) { | ||
192 | await sendVerifyUserEmail(user) | ||
193 | } | ||
194 | |||
176 | return res.type('json').status(204).end() | 195 | return res.type('json').status(204).end() |
177 | } | 196 | } |
178 | 197 | ||
@@ -261,6 +280,30 @@ async function resetUserPassword (req: express.Request, res: express.Response, n | |||
261 | return res.status(204).end() | 280 | return res.status(204).end() |
262 | } | 281 | } |
263 | 282 | ||
283 | async function sendVerifyUserEmail (user: UserModel) { | ||
284 | const verificationString = await Redis.Instance.setVerifyEmailVerificationString(user.id) | ||
285 | const url = CONFIG.WEBSERVER.URL + '/verify-account/email?userId=' + user.id + '&verificationString=' + verificationString | ||
286 | await Emailer.Instance.addVerifyEmailJob(user.email, url) | ||
287 | return | ||
288 | } | ||
289 | |||
290 | async function askSendVerifyUserEmail (req: express.Request, res: express.Response, next: express.NextFunction) { | ||
291 | const user = res.locals.user as UserModel | ||
292 | |||
293 | await sendVerifyUserEmail(user) | ||
294 | |||
295 | return res.status(204).end() | ||
296 | } | ||
297 | |||
298 | async function verifyUserEmail (req: express.Request, res: express.Response, next: express.NextFunction) { | ||
299 | const user = res.locals.user as UserModel | ||
300 | user.emailVerified = true | ||
301 | |||
302 | await user.save() | ||
303 | |||
304 | return res.status(204).end() | ||
305 | } | ||
306 | |||
264 | function success (req: express.Request, res: express.Response, next: express.NextFunction) { | 307 | function success (req: express.Request, res: express.Response, next: express.NextFunction) { |
265 | res.end() | 308 | res.end() |
266 | } | 309 | } |
diff --git a/server/helpers/audit-logger.ts b/server/helpers/audit-logger.ts index db20df20f..7db72b69c 100644 --- a/server/helpers/audit-logger.ts +++ b/server/helpers/audit-logger.ts | |||
@@ -234,6 +234,7 @@ const customConfigKeysToKeep = [ | |||
234 | 'cache-captions-size', | 234 | 'cache-captions-size', |
235 | 'signup-enabled', | 235 | 'signup-enabled', |
236 | 'signup-limit', | 236 | 'signup-limit', |
237 | 'signup-requiresEmailVerification', | ||
237 | 'admin-email', | 238 | 'admin-email', |
238 | 'user-videoQuota', | 239 | 'user-videoQuota', |
239 | 'transcoding-enabled', | 240 | 'transcoding-enabled', |
diff --git a/server/helpers/custom-validators/users.ts b/server/helpers/custom-validators/users.ts index 8d6247e41..90fc74a48 100644 --- a/server/helpers/custom-validators/users.ts +++ b/server/helpers/custom-validators/users.ts | |||
@@ -33,6 +33,10 @@ function isUserDescriptionValid (value: string) { | |||
33 | return value === null || (exists(value) && validator.isLength(value, CONSTRAINTS_FIELDS.USERS.DESCRIPTION)) | 33 | return value === null || (exists(value) && validator.isLength(value, CONSTRAINTS_FIELDS.USERS.DESCRIPTION)) |
34 | } | 34 | } |
35 | 35 | ||
36 | function isUserEmailVerifiedValid (value: any) { | ||
37 | return isBooleanValid(value) | ||
38 | } | ||
39 | |||
36 | const nsfwPolicies = values(NSFW_POLICY_TYPES) | 40 | const nsfwPolicies = values(NSFW_POLICY_TYPES) |
37 | function isUserNSFWPolicyValid (value: any) { | 41 | function isUserNSFWPolicyValid (value: any) { |
38 | return exists(value) && nsfwPolicies.indexOf(value) !== -1 | 42 | return exists(value) && nsfwPolicies.indexOf(value) !== -1 |
@@ -72,6 +76,7 @@ export { | |||
72 | isUserVideoQuotaValid, | 76 | isUserVideoQuotaValid, |
73 | isUserVideoQuotaDailyValid, | 77 | isUserVideoQuotaDailyValid, |
74 | isUserUsernameValid, | 78 | isUserUsernameValid, |
79 | isUserEmailVerifiedValid, | ||
75 | isUserNSFWPolicyValid, | 80 | isUserNSFWPolicyValid, |
76 | isUserAutoPlayVideoValid, | 81 | isUserAutoPlayVideoValid, |
77 | isUserDisplayNameValid, | 82 | isUserDisplayNameValid, |
diff --git a/server/initializers/checker.ts b/server/initializers/checker.ts index 916e9067e..ee02ecf48 100644 --- a/server/initializers/checker.ts +++ b/server/initializers/checker.ts | |||
@@ -49,7 +49,8 @@ function checkMissedConfig () { | |||
49 | 'log.level', | 49 | 'log.level', |
50 | 'user.video_quota', 'user.video_quota_daily', | 50 | 'user.video_quota', 'user.video_quota_daily', |
51 | 'cache.previews.size', 'admin.email', | 51 | 'cache.previews.size', 'admin.email', |
52 | 'signup.enabled', 'signup.limit', 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', | 52 | 'signup.enabled', 'signup.limit', 'signup.requires_email_verification', |
53 | 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', | ||
53 | 'transcoding.enabled', 'transcoding.threads', | 54 | 'transcoding.enabled', 'transcoding.threads', |
54 | 'import.videos.http.enabled', | 55 | 'import.videos.http.enabled', |
55 | 'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route', | 56 | 'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route', |
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 2d9a2e670..5d93c6b82 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -15,7 +15,7 @@ let config: IConfig = require('config') | |||
15 | 15 | ||
16 | // --------------------------------------------------------------------------- | 16 | // --------------------------------------------------------------------------- |
17 | 17 | ||
18 | const LAST_MIGRATION_VERSION = 260 | 18 | const LAST_MIGRATION_VERSION = 265 |
19 | 19 | ||
20 | // --------------------------------------------------------------------------- | 20 | // --------------------------------------------------------------------------- |
21 | 21 | ||
@@ -204,6 +204,7 @@ const CONFIG = { | |||
204 | SIGNUP: { | 204 | SIGNUP: { |
205 | get ENABLED () { return config.get<boolean>('signup.enabled') }, | 205 | get ENABLED () { return config.get<boolean>('signup.enabled') }, |
206 | get LIMIT () { return config.get<number>('signup.limit') }, | 206 | get LIMIT () { return config.get<number>('signup.limit') }, |
207 | get REQUIRES_EMAIL_VERIFICATION () { return config.get<boolean>('signup.requires_email_verification') }, | ||
207 | FILTERS: { | 208 | FILTERS: { |
208 | CIDR: { | 209 | CIDR: { |
209 | get WHITELIST () { return config.get<string[]>('signup.filters.cidr.whitelist') }, | 210 | get WHITELIST () { return config.get<string[]>('signup.filters.cidr.whitelist') }, |
@@ -500,6 +501,8 @@ const BCRYPT_SALT_SIZE = 10 | |||
500 | 501 | ||
501 | const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes | 502 | const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes |
502 | 503 | ||
504 | const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes | ||
505 | |||
503 | const NSFW_POLICY_TYPES: { [ id: string]: NSFWPolicyType } = { | 506 | const NSFW_POLICY_TYPES: { [ id: string]: NSFWPolicyType } = { |
504 | DO_NOT_LIST: 'do_not_list', | 507 | DO_NOT_LIST: 'do_not_list', |
505 | BLUR: 'blur', | 508 | BLUR: 'blur', |
@@ -661,6 +664,7 @@ export { | |||
661 | VIDEO_ABUSE_STATES, | 664 | VIDEO_ABUSE_STATES, |
662 | JOB_REQUEST_TIMEOUT, | 665 | JOB_REQUEST_TIMEOUT, |
663 | USER_PASSWORD_RESET_LIFETIME, | 666 | USER_PASSWORD_RESET_LIFETIME, |
667 | USER_EMAIL_VERIFY_LIFETIME, | ||
664 | IMAGE_MIMETYPE_EXT, | 668 | IMAGE_MIMETYPE_EXT, |
665 | SCHEDULER_INTERVALS_MS, | 669 | SCHEDULER_INTERVALS_MS, |
666 | REPEAT_JOBS, | 670 | REPEAT_JOBS, |
diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts index d4aaec8fe..818bb04a2 100644 --- a/server/initializers/installer.ts +++ b/server/initializers/installer.ts | |||
@@ -122,6 +122,7 @@ async function createOAuthAdminIfNotExist () { | |||
122 | email, | 122 | email, |
123 | password, | 123 | password, |
124 | role, | 124 | role, |
125 | verified: true, | ||
125 | nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY, | 126 | nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY, |
126 | videoQuota: -1, | 127 | videoQuota: -1, |
127 | videoQuotaDaily: -1 | 128 | videoQuotaDaily: -1 |
diff --git a/server/initializers/migrations/0265-user-email-verified.ts b/server/initializers/migrations/0265-user-email-verified.ts new file mode 100644 index 000000000..59dfdad2b --- /dev/null +++ b/server/initializers/migrations/0265-user-email-verified.ts | |||
@@ -0,0 +1,24 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | |||
3 | async function up (utils: { | ||
4 | transaction: Sequelize.Transaction | ||
5 | queryInterface: Sequelize.QueryInterface | ||
6 | sequelize: Sequelize.Sequelize | ||
7 | }): Promise<any> { | ||
8 | { | ||
9 | const data = { | ||
10 | type: Sequelize.BOOLEAN, | ||
11 | allowNull: true, | ||
12 | defaultValue: null | ||
13 | } | ||
14 | |||
15 | await utils.queryInterface.addColumn('user', 'emailVerified', data) | ||
16 | } | ||
17 | |||
18 | } | ||
19 | |||
20 | function down (options) { | ||
21 | throw new Error('Not implemented.') | ||
22 | } | ||
23 | |||
24 | export { up, down } | ||
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts index bf8e5b6c3..9327792fb 100644 --- a/server/lib/emailer.ts +++ b/server/lib/emailer.ts | |||
@@ -89,6 +89,23 @@ class Emailer { | |||
89 | return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) | 89 | return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) |
90 | } | 90 | } |
91 | 91 | ||
92 | addVerifyEmailJob (to: string, verifyEmailUrl: string) { | ||
93 | const text = `Welcome to PeerTube,\n\n` + | ||
94 | `To start using PeerTube on ${CONFIG.WEBSERVER.HOST} you must verify your email! ` + | ||
95 | `Please follow this link to verify this email belongs to you: ${verifyEmailUrl}\n\n` + | ||
96 | `If you are not the person who initiated this request, please ignore this email.\n\n` + | ||
97 | `Cheers,\n` + | ||
98 | `PeerTube.` | ||
99 | |||
100 | const emailPayload: EmailPayload = { | ||
101 | to: [ to ], | ||
102 | subject: 'Verify your PeerTube email', | ||
103 | text | ||
104 | } | ||
105 | |||
106 | return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) | ||
107 | } | ||
108 | |||
92 | async addVideoAbuseReportJob (videoId: number) { | 109 | async addVideoAbuseReportJob (videoId: number) { |
93 | const video = await VideoModel.load(videoId) | 110 | const video = await VideoModel.load(videoId) |
94 | if (!video) throw new Error('Unknown Video id during Abuse report.') | 111 | if (!video) throw new Error('Unknown Video id during Abuse report.') |
diff --git a/server/lib/oauth-model.ts b/server/lib/oauth-model.ts index 09eaf75d1..2f8667e19 100644 --- a/server/lib/oauth-model.ts +++ b/server/lib/oauth-model.ts | |||
@@ -3,6 +3,7 @@ import { logger } from '../helpers/logger' | |||
3 | import { UserModel } from '../models/account/user' | 3 | import { UserModel } from '../models/account/user' |
4 | import { OAuthClientModel } from '../models/oauth/oauth-client' | 4 | import { OAuthClientModel } from '../models/oauth/oauth-client' |
5 | import { OAuthTokenModel } from '../models/oauth/oauth-token' | 5 | import { OAuthTokenModel } from '../models/oauth/oauth-token' |
6 | import { CONFIG } from '../initializers/constants' | ||
6 | 7 | ||
7 | type TokenInfo = { accessToken: string, refreshToken: string, accessTokenExpiresAt: Date, refreshTokenExpiresAt: Date } | 8 | type TokenInfo = { accessToken: string, refreshToken: string, accessTokenExpiresAt: Date, refreshTokenExpiresAt: Date } |
8 | 9 | ||
@@ -37,6 +38,10 @@ async function getUser (usernameOrEmail: string, password: string) { | |||
37 | 38 | ||
38 | if (user.blocked) throw new AccessDeniedError('User is blocked.') | 39 | if (user.blocked) throw new AccessDeniedError('User is blocked.') |
39 | 40 | ||
41 | if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION && user.emailVerified === false) { | ||
42 | throw new AccessDeniedError('User email is not verified.') | ||
43 | } | ||
44 | |||
40 | return user | 45 | return user |
41 | } | 46 | } |
42 | 47 | ||
diff --git a/server/lib/redis.ts b/server/lib/redis.ts index 0b4b41e4e..e4e435659 100644 --- a/server/lib/redis.ts +++ b/server/lib/redis.ts | |||
@@ -2,7 +2,7 @@ import * as express from 'express' | |||
2 | import { createClient, RedisClient } from 'redis' | 2 | import { createClient, RedisClient } from 'redis' |
3 | import { logger } from '../helpers/logger' | 3 | import { logger } from '../helpers/logger' |
4 | import { generateRandomString } from '../helpers/utils' | 4 | import { generateRandomString } from '../helpers/utils' |
5 | import { CONFIG, USER_PASSWORD_RESET_LIFETIME, VIDEO_VIEW_LIFETIME } from '../initializers' | 5 | import { CONFIG, USER_PASSWORD_RESET_LIFETIME, USER_EMAIL_VERIFY_LIFETIME, VIDEO_VIEW_LIFETIME } from '../initializers' |
6 | 6 | ||
7 | type CachedRoute = { | 7 | type CachedRoute = { |
8 | body: string, | 8 | body: string, |
@@ -60,6 +60,18 @@ class Redis { | |||
60 | return this.getValue(this.generateResetPasswordKey(userId)) | 60 | return this.getValue(this.generateResetPasswordKey(userId)) |
61 | } | 61 | } |
62 | 62 | ||
63 | async setVerifyEmailVerificationString (userId: number) { | ||
64 | const generatedString = await generateRandomString(32) | ||
65 | |||
66 | await this.setValue(this.generateVerifyEmailKey(userId), generatedString, USER_EMAIL_VERIFY_LIFETIME) | ||
67 | |||
68 | return generatedString | ||
69 | } | ||
70 | |||
71 | async getVerifyEmailLink (userId: number) { | ||
72 | return this.getValue(this.generateVerifyEmailKey(userId)) | ||
73 | } | ||
74 | |||
63 | setIPVideoView (ip: string, videoUUID: string) { | 75 | setIPVideoView (ip: string, videoUUID: string) { |
64 | return this.setValue(this.buildViewKey(ip, videoUUID), '1', VIDEO_VIEW_LIFETIME) | 76 | return this.setValue(this.buildViewKey(ip, videoUUID), '1', VIDEO_VIEW_LIFETIME) |
65 | } | 77 | } |
@@ -135,6 +147,10 @@ class Redis { | |||
135 | return 'reset-password-' + userId | 147 | return 'reset-password-' + userId |
136 | } | 148 | } |
137 | 149 | ||
150 | generateVerifyEmailKey (userId: number) { | ||
151 | return 'verify-email-' + userId | ||
152 | } | ||
153 | |||
138 | buildViewKey (ip: string, videoUUID: string) { | 154 | buildViewKey (ip: string, videoUUID: string) { |
139 | return videoUUID + '-' + ip | 155 | return videoUUID + '-' + ip |
140 | } | 156 | } |
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts index 6c5e783e9..a595c39ec 100644 --- a/server/middlewares/validators/users.ts +++ b/server/middlewares/validators/users.ts | |||
@@ -248,6 +248,48 @@ const usersResetPasswordValidator = [ | |||
248 | } | 248 | } |
249 | ] | 249 | ] |
250 | 250 | ||
251 | const usersAskSendVerifyEmailValidator = [ | ||
252 | body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'), | ||
253 | |||
254 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
255 | logger.debug('Checking askUsersSendVerifyEmail parameters', { parameters: req.body }) | ||
256 | |||
257 | if (areValidationErrors(req, res)) return | ||
258 | const exists = await checkUserEmailExist(req.body.email, res, false) | ||
259 | if (!exists) { | ||
260 | logger.debug('User with email %s does not exist (asking verify email).', req.body.email) | ||
261 | // Do not leak our emails | ||
262 | return res.status(204).end() | ||
263 | } | ||
264 | |||
265 | return next() | ||
266 | } | ||
267 | ] | ||
268 | |||
269 | const usersVerifyEmailValidator = [ | ||
270 | param('id').isInt().not().isEmpty().withMessage('Should have a valid id'), | ||
271 | body('verificationString').not().isEmpty().withMessage('Should have a valid verification string'), | ||
272 | |||
273 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
274 | logger.debug('Checking usersVerifyEmail parameters', { parameters: req.params }) | ||
275 | |||
276 | if (areValidationErrors(req, res)) return | ||
277 | if (!await checkUserIdExist(req.params.id, res)) return | ||
278 | |||
279 | const user = res.locals.user as UserModel | ||
280 | const redisVerificationString = await Redis.Instance.getVerifyEmailLink(user.id) | ||
281 | |||
282 | if (redisVerificationString !== req.body.verificationString) { | ||
283 | return res | ||
284 | .status(403) | ||
285 | .send({ error: 'Invalid verification string.' }) | ||
286 | .end() | ||
287 | } | ||
288 | |||
289 | return next() | ||
290 | } | ||
291 | ] | ||
292 | |||
251 | // --------------------------------------------------------------------------- | 293 | // --------------------------------------------------------------------------- |
252 | 294 | ||
253 | export { | 295 | export { |
@@ -263,7 +305,9 @@ export { | |||
263 | ensureUserRegistrationAllowedForIP, | 305 | ensureUserRegistrationAllowedForIP, |
264 | usersGetValidator, | 306 | usersGetValidator, |
265 | usersAskResetPasswordValidator, | 307 | usersAskResetPasswordValidator, |
266 | usersResetPasswordValidator | 308 | usersResetPasswordValidator, |
309 | usersAskSendVerifyEmailValidator, | ||
310 | usersVerifyEmailValidator | ||
267 | } | 311 | } |
268 | 312 | ||
269 | // --------------------------------------------------------------------------- | 313 | // --------------------------------------------------------------------------- |
diff --git a/server/models/account/user.ts b/server/models/account/user.ts index bae683b12..89265774b 100644 --- a/server/models/account/user.ts +++ b/server/models/account/user.ts | |||
@@ -24,6 +24,7 @@ import { | |||
24 | isUserBlockedReasonValid, | 24 | isUserBlockedReasonValid, |
25 | isUserBlockedValid, | 25 | isUserBlockedValid, |
26 | isUserNSFWPolicyValid, | 26 | isUserNSFWPolicyValid, |
27 | isUserEmailVerifiedValid, | ||
27 | isUserPasswordValid, | 28 | isUserPasswordValid, |
28 | isUserRoleValid, | 29 | isUserRoleValid, |
29 | isUserUsernameValid, | 30 | isUserUsernameValid, |
@@ -92,6 +93,12 @@ export class UserModel extends Model<UserModel> { | |||
92 | @Column(DataType.STRING(400)) | 93 | @Column(DataType.STRING(400)) |
93 | email: string | 94 | email: string |
94 | 95 | ||
96 | @AllowNull(true) | ||
97 | @Default(null) | ||
98 | @Is('UserEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean')) | ||
99 | @Column | ||
100 | emailVerified: boolean | ||
101 | |||
95 | @AllowNull(false) | 102 | @AllowNull(false) |
96 | @Is('UserNSFWPolicy', value => throwIfNotValid(value, isUserNSFWPolicyValid, 'NSFW policy')) | 103 | @Is('UserNSFWPolicy', value => throwIfNotValid(value, isUserNSFWPolicyValid, 'NSFW policy')) |
97 | @Column(DataType.ENUM(values(NSFW_POLICY_TYPES))) | 104 | @Column(DataType.ENUM(values(NSFW_POLICY_TYPES))) |
@@ -304,6 +311,7 @@ export class UserModel extends Model<UserModel> { | |||
304 | id: this.id, | 311 | id: this.id, |
305 | username: this.username, | 312 | username: this.username, |
306 | email: this.email, | 313 | email: this.email, |
314 | emailVerified: this.emailVerified, | ||
307 | nsfwPolicy: this.nsfwPolicy, | 315 | nsfwPolicy: this.nsfwPolicy, |
308 | autoPlayVideo: this.autoPlayVideo, | 316 | autoPlayVideo: this.autoPlayVideo, |
309 | role: this.role, | 317 | role: this.role, |
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts index ecfb76d47..d807f910b 100644 --- a/server/tests/api/check-params/config.ts +++ b/server/tests/api/check-params/config.ts | |||
@@ -42,7 +42,8 @@ describe('Test config API validators', function () { | |||
42 | }, | 42 | }, |
43 | signup: { | 43 | signup: { |
44 | enabled: false, | 44 | enabled: false, |
45 | limit: 5 | 45 | limit: 5, |
46 | requiresEmailVerification: false | ||
46 | }, | 47 | }, |
47 | admin: { | 48 | admin: { |
48 | email: 'superadmin1@example.com' | 49 | email: 'superadmin1@example.com' |
diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts index 8b2ed1b04..95903c8a5 100644 --- a/server/tests/api/check-params/users.ts +++ b/server/tests/api/check-params/users.ts | |||
@@ -737,6 +737,28 @@ describe('Test users API validators', function () { | |||
737 | }) | 737 | }) |
738 | }) | 738 | }) |
739 | 739 | ||
740 | describe('When asking for an account verification email', function () { | ||
741 | const path = '/api/v1/users/ask-send-verify-email' | ||
742 | |||
743 | it('Should fail with a missing email', async function () { | ||
744 | const fields = {} | ||
745 | |||
746 | await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) | ||
747 | }) | ||
748 | |||
749 | it('Should fail with an invalid email', async function () { | ||
750 | const fields = { email: 'hello' } | ||
751 | |||
752 | await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) | ||
753 | }) | ||
754 | |||
755 | it('Should succeed with the correct params', async function () { | ||
756 | const fields = { email: 'admin@example.com' } | ||
757 | |||
758 | await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 204 }) | ||
759 | }) | ||
760 | }) | ||
761 | |||
740 | after(async function () { | 762 | after(async function () { |
741 | killallServers([ server, serverWithRegistrationDisabled ]) | 763 | killallServers([ server, serverWithRegistrationDisabled ]) |
742 | 764 | ||
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts index ece4118a6..facd1688d 100644 --- a/server/tests/api/server/config.ts +++ b/server/tests/api/server/config.ts | |||
@@ -35,6 +35,7 @@ function checkInitialConfig (data: CustomConfig) { | |||
35 | expect(data.cache.captions.size).to.equal(1) | 35 | expect(data.cache.captions.size).to.equal(1) |
36 | expect(data.signup.enabled).to.be.true | 36 | expect(data.signup.enabled).to.be.true |
37 | expect(data.signup.limit).to.equal(4) | 37 | expect(data.signup.limit).to.equal(4) |
38 | expect(data.signup.requiresEmailVerification).to.be.false | ||
38 | expect(data.admin.email).to.equal('admin1@example.com') | 39 | expect(data.admin.email).to.equal('admin1@example.com') |
39 | expect(data.user.videoQuota).to.equal(5242880) | 40 | expect(data.user.videoQuota).to.equal(5242880) |
40 | expect(data.user.videoQuotaDaily).to.equal(-1) | 41 | expect(data.user.videoQuotaDaily).to.equal(-1) |
@@ -64,6 +65,7 @@ function checkUpdatedConfig (data: CustomConfig) { | |||
64 | expect(data.cache.captions.size).to.equal(3) | 65 | expect(data.cache.captions.size).to.equal(3) |
65 | expect(data.signup.enabled).to.be.false | 66 | expect(data.signup.enabled).to.be.false |
66 | expect(data.signup.limit).to.equal(5) | 67 | expect(data.signup.limit).to.equal(5) |
68 | expect(data.signup.requiresEmailVerification).to.be.true | ||
67 | expect(data.admin.email).to.equal('superadmin1@example.com') | 69 | expect(data.admin.email).to.equal('superadmin1@example.com') |
68 | expect(data.user.videoQuota).to.equal(5242881) | 70 | expect(data.user.videoQuota).to.equal(5242881) |
69 | expect(data.user.videoQuotaDaily).to.equal(318742) | 71 | expect(data.user.videoQuotaDaily).to.equal(318742) |
@@ -148,7 +150,8 @@ describe('Test config', function () { | |||
148 | }, | 150 | }, |
149 | signup: { | 151 | signup: { |
150 | enabled: false, | 152 | enabled: false, |
151 | limit: 5 | 153 | limit: 5, |
154 | requiresEmailVerification: true | ||
152 | }, | 155 | }, |
153 | admin: { | 156 | admin: { |
154 | email: 'superadmin1@example.com' | 157 | email: 'superadmin1@example.com' |
diff --git a/server/tests/api/server/email.ts b/server/tests/api/server/email.ts index db937f288..713a27143 100644 --- a/server/tests/api/server/email.ts +++ b/server/tests/api/server/email.ts | |||
@@ -5,6 +5,7 @@ import 'mocha' | |||
5 | import { | 5 | import { |
6 | addVideoToBlacklist, | 6 | addVideoToBlacklist, |
7 | askResetPassword, | 7 | askResetPassword, |
8 | askSendVerifyEmail, | ||
8 | blockUser, | 9 | blockUser, |
9 | createUser, removeVideoFromBlacklist, | 10 | createUser, removeVideoFromBlacklist, |
10 | reportVideoAbuse, | 11 | reportVideoAbuse, |
@@ -12,7 +13,8 @@ import { | |||
12 | runServer, | 13 | runServer, |
13 | unblockUser, | 14 | unblockUser, |
14 | uploadVideo, | 15 | uploadVideo, |
15 | userLogin | 16 | userLogin, |
17 | verifyEmail | ||
16 | } from '../../utils' | 18 | } from '../../utils' |
17 | import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index' | 19 | import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index' |
18 | import { mockSmtpServer } from '../../utils/miscs/email' | 20 | import { mockSmtpServer } from '../../utils/miscs/email' |
@@ -207,6 +209,44 @@ describe('Test emails', function () { | |||
207 | }) | 209 | }) |
208 | }) | 210 | }) |
209 | 211 | ||
212 | describe('When verifying a user email', function () { | ||
213 | |||
214 | it('Should ask to send the verification email', async function () { | ||
215 | this.timeout(10000) | ||
216 | |||
217 | await askSendVerifyEmail(server.url, 'user_1@example.com') | ||
218 | |||
219 | await waitJobs(server) | ||
220 | expect(emails).to.have.lengthOf(7) | ||
221 | |||
222 | const email = emails[6] | ||
223 | |||
224 | expect(email['from'][0]['address']).equal('test-admin@localhost') | ||
225 | expect(email['to'][0]['address']).equal('user_1@example.com') | ||
226 | expect(email['subject']).contains('Verify') | ||
227 | |||
228 | const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text']) | ||
229 | expect(verificationStringMatches).not.to.be.null | ||
230 | |||
231 | verificationString = verificationStringMatches[1] | ||
232 | expect(verificationString).to.not.be.undefined | ||
233 | expect(verificationString).to.have.length.above(2) | ||
234 | |||
235 | const userIdMatches = /userId=([0-9]+)/.exec(email['text']) | ||
236 | expect(userIdMatches).not.to.be.null | ||
237 | |||
238 | userId = parseInt(userIdMatches[1], 10) | ||
239 | }) | ||
240 | |||
241 | it('Should not verify the email with an invalid verification string', async function () { | ||
242 | await verifyEmail(server.url, userId, verificationString + 'b', 403) | ||
243 | }) | ||
244 | |||
245 | it('Should verify the email', async function () { | ||
246 | await verifyEmail(server.url, userId, verificationString) | ||
247 | }) | ||
248 | }) | ||
249 | |||
210 | after(async function () { | 250 | after(async function () { |
211 | killallServers([ server ]) | 251 | killallServers([ server ]) |
212 | }) | 252 | }) |
diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts index 4ce87fb91..21d75da3e 100644 --- a/server/tests/api/users/index.ts +++ b/server/tests/api/users/index.ts | |||
@@ -1,3 +1,4 @@ | |||
1 | import './user-subscriptions' | 1 | import './user-subscriptions' |
2 | import './users' | 2 | import './users' |
3 | import './users-verification' | ||
3 | import './users-multiple-servers' | 4 | import './users-multiple-servers' |
diff --git a/server/tests/api/users/users-verification.ts b/server/tests/api/users/users-verification.ts new file mode 100644 index 000000000..fa5f5e371 --- /dev/null +++ b/server/tests/api/users/users-verification.ts | |||
@@ -0,0 +1,133 @@ | |||
1 | /* tslint:disable:no-unused-expression */ | ||
2 | |||
3 | import * as chai from 'chai' | ||
4 | import 'mocha' | ||
5 | import { | ||
6 | registerUser, flushTests, getUserInformation, getMyUserInformation, killallServers, | ||
7 | userLogin, login, runServer, ServerInfo, verifyEmail, updateCustomSubConfig | ||
8 | } from '../../utils' | ||
9 | import { setAccessTokensToServers } from '../../utils/users/login' | ||
10 | import { mockSmtpServer } from '../../utils/miscs/email' | ||
11 | import { waitJobs } from '../../utils/server/jobs' | ||
12 | |||
13 | const expect = chai.expect | ||
14 | |||
15 | describe('Test users account verification', function () { | ||
16 | let server: ServerInfo | ||
17 | let userId: number | ||
18 | let verificationString: string | ||
19 | let expectedEmailsLength = 0 | ||
20 | const user1 = { | ||
21 | username: 'user_1', | ||
22 | password: 'super password' | ||
23 | } | ||
24 | const user2 = { | ||
25 | username: 'user_2', | ||
26 | password: 'super password' | ||
27 | } | ||
28 | const emails: object[] = [] | ||
29 | |||
30 | before(async function () { | ||
31 | this.timeout(30000) | ||
32 | |||
33 | await mockSmtpServer(emails) | ||
34 | |||
35 | await flushTests() | ||
36 | |||
37 | const overrideConfig = { | ||
38 | smtp: { | ||
39 | hostname: 'localhost' | ||
40 | } | ||
41 | } | ||
42 | server = await runServer(1, overrideConfig) | ||
43 | |||
44 | await setAccessTokensToServers([ server ]) | ||
45 | }) | ||
46 | |||
47 | it('Should register user and send verification email if verification required', async function () { | ||
48 | this.timeout(5000) | ||
49 | await updateCustomSubConfig(server.url, server.accessToken, { | ||
50 | signup: { | ||
51 | enabled: true, | ||
52 | requiresEmailVerification: true, | ||
53 | limit: 10 | ||
54 | } | ||
55 | }) | ||
56 | |||
57 | await registerUser(server.url, user1.username, user1.password) | ||
58 | |||
59 | await waitJobs(server) | ||
60 | expectedEmailsLength++ | ||
61 | expect(emails).to.have.lengthOf(expectedEmailsLength) | ||
62 | |||
63 | const email = emails[expectedEmailsLength - 1] | ||
64 | |||
65 | const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text']) | ||
66 | expect(verificationStringMatches).not.to.be.null | ||
67 | |||
68 | verificationString = verificationStringMatches[1] | ||
69 | expect(verificationString).to.have.length.above(2) | ||
70 | |||
71 | const userIdMatches = /userId=([0-9]+)/.exec(email['text']) | ||
72 | expect(userIdMatches).not.to.be.null | ||
73 | |||
74 | userId = parseInt(userIdMatches[1], 10) | ||
75 | |||
76 | const resUserInfo = await getUserInformation(server.url, server.accessToken, userId) | ||
77 | expect(resUserInfo.body.emailVerified).to.be.false | ||
78 | }) | ||
79 | |||
80 | it('Should not allow login for user with unverified email', async function () { | ||
81 | const resLogin = await login(server.url, server.client, user1, 400) | ||
82 | expect(resLogin.body.error).to.contain('User email is not verified.') | ||
83 | }) | ||
84 | |||
85 | it('Should verify the user via email and allow login', async function () { | ||
86 | await verifyEmail(server.url, userId, verificationString) | ||
87 | await login(server.url, server.client, user1) | ||
88 | const resUserVerified = await getUserInformation(server.url, server.accessToken, userId) | ||
89 | expect(resUserVerified.body.emailVerified).to.be.true | ||
90 | }) | ||
91 | |||
92 | it('Should register user not requiring email verification if setting not enabled', async function () { | ||
93 | this.timeout(5000) | ||
94 | await updateCustomSubConfig(server.url, server.accessToken, { | ||
95 | signup: { | ||
96 | enabled: true, | ||
97 | requiresEmailVerification: false, | ||
98 | limit: 10 | ||
99 | } | ||
100 | }) | ||
101 | |||
102 | await registerUser(server.url, user2.username, user2.password) | ||
103 | |||
104 | await waitJobs(server) | ||
105 | expect(emails).to.have.lengthOf(expectedEmailsLength) | ||
106 | |||
107 | const accessToken = await userLogin(server, user2) | ||
108 | |||
109 | const resMyUserInfo = await getMyUserInformation(server.url, accessToken) | ||
110 | expect(resMyUserInfo.body.emailVerified).to.be.null | ||
111 | }) | ||
112 | |||
113 | it('Should allow login for user with unverified email when setting later enabled', async function () { | ||
114 | await updateCustomSubConfig(server.url, server.accessToken, { | ||
115 | signup: { | ||
116 | enabled: true, | ||
117 | requiresEmailVerification: true, | ||
118 | limit: 10 | ||
119 | } | ||
120 | }) | ||
121 | |||
122 | await userLogin(server, user2) | ||
123 | }) | ||
124 | |||
125 | after(async function () { | ||
126 | killallServers([ server ]) | ||
127 | |||
128 | // Keep the logs if the test failed | ||
129 | if (this[ 'ok' ]) { | ||
130 | await flushTests() | ||
131 | } | ||
132 | }) | ||
133 | }) | ||
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts index 04dcc8fd1..c0dd587ee 100644 --- a/server/tests/api/users/users.ts +++ b/server/tests/api/users/users.ts | |||
@@ -7,7 +7,7 @@ import { | |||
7 | createUser, flushTests, getBlacklistedVideosList, getMyUserInformation, getMyUserVideoQuotaUsed, getMyUserVideoRating, | 7 | createUser, flushTests, getBlacklistedVideosList, getMyUserInformation, getMyUserVideoQuotaUsed, getMyUserVideoRating, |
8 | getUserInformation, getUsersList, getUsersListPaginationAndSort, getVideosList, killallServers, login, makePutBodyRequest, rateVideo, | 8 | getUserInformation, getUsersList, getUsersListPaginationAndSort, getVideosList, killallServers, login, makePutBodyRequest, rateVideo, |
9 | registerUser, removeUser, removeVideo, runServer, ServerInfo, testImage, updateMyAvatar, updateMyUser, updateUser, uploadVideo, userLogin, | 9 | registerUser, removeUser, removeVideo, runServer, ServerInfo, testImage, updateMyAvatar, updateMyUser, updateUser, uploadVideo, userLogin, |
10 | deleteMe, blockUser, unblockUser | 10 | deleteMe, blockUser, unblockUser, updateCustomSubConfig |
11 | } from '../../utils/index' | 11 | } from '../../utils/index' |
12 | import { follow } from '../../utils/server/follows' | 12 | import { follow } from '../../utils/server/follows' |
13 | import { setAccessTokensToServers } from '../../utils/users/login' | 13 | import { setAccessTokensToServers } from '../../utils/users/login' |
diff --git a/server/tests/utils/server/config.ts b/server/tests/utils/server/config.ts index 799c31ae5..b85e02ab7 100644 --- a/server/tests/utils/server/config.ts +++ b/server/tests/utils/server/config.ts | |||
@@ -74,7 +74,8 @@ function updateCustomSubConfig (url: string, token: string, newConfig: any) { | |||
74 | }, | 74 | }, |
75 | signup: { | 75 | signup: { |
76 | enabled: false, | 76 | enabled: false, |
77 | limit: 5 | 77 | limit: 5, |
78 | requiresEmailVerification: false | ||
78 | }, | 79 | }, |
79 | admin: { | 80 | admin: { |
80 | email: 'superadmin1@example.com' | 81 | email: 'superadmin1@example.com' |
diff --git a/server/tests/utils/users/users.ts b/server/tests/utils/users/users.ts index 5dba34b69..cd1b07701 100644 --- a/server/tests/utils/users/users.ts +++ b/server/tests/utils/users/users.ts | |||
@@ -246,6 +246,28 @@ function resetPassword (url: string, userId: number, verificationString: string, | |||
246 | }) | 246 | }) |
247 | } | 247 | } |
248 | 248 | ||
249 | function askSendVerifyEmail (url: string, email: string) { | ||
250 | const path = '/api/v1/users/ask-send-verify-email' | ||
251 | |||
252 | return makePostBodyRequest({ | ||
253 | url, | ||
254 | path, | ||
255 | fields: { email }, | ||
256 | statusCodeExpected: 204 | ||
257 | }) | ||
258 | } | ||
259 | |||
260 | function verifyEmail (url: string, userId: number, verificationString: string, statusCodeExpected = 204) { | ||
261 | const path = '/api/v1/users/' + userId + '/verify-email' | ||
262 | |||
263 | return makePostBodyRequest({ | ||
264 | url, | ||
265 | path, | ||
266 | fields: { verificationString }, | ||
267 | statusCodeExpected | ||
268 | }) | ||
269 | } | ||
270 | |||
249 | // --------------------------------------------------------------------------- | 271 | // --------------------------------------------------------------------------- |
250 | 272 | ||
251 | export { | 273 | export { |
@@ -265,5 +287,7 @@ export { | |||
265 | unblockUser, | 287 | unblockUser, |
266 | askResetPassword, | 288 | askResetPassword, |
267 | resetPassword, | 289 | resetPassword, |
268 | updateMyAvatar | 290 | updateMyAvatar, |
291 | askSendVerifyEmail, | ||
292 | verifyEmail | ||
269 | } | 293 | } |