aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
authorJosh Morel <morel.josh@hotmail.com>2018-08-31 03:18:19 -0400
committerChocobozzz <me@florianbigard.com>2018-08-31 09:18:19 +0200
commitd9eaee3939bf2e93e5d775d32bce77842201faba (patch)
treec115acb3611986b98f51b3addf29ebe66f63ee7f /server
parent04291e1ba44032165388758e993d385a10c1c5a1 (diff)
downloadPeerTube-d9eaee3939bf2e93e5d775d32bce77842201faba.tar.gz
PeerTube-d9eaee3939bf2e93e5d775d32bce77842201faba.tar.zst
PeerTube-d9eaee3939bf2e93e5d775d32bce77842201faba.zip
add user account email verificiation (#977)
* add user account email verificiation includes server and client code to: * enable verificationRequired via custom config * send verification email with registration * ask for verification email * verify via email * prevent login if not verified and required * conditional client links to ask for new verification email * allow login for verified=null these are users created when verification not required should still be able to login when verification is enabled * refactor email verifcation pr * change naming from verified to emailVerified * change naming from askVerifyEmail to askSendVerifyEmail * undo unrelated automatic prettier formatting on api/config * use redirectService for home * remove redundant success notification on email verified * revert test.yaml smpt host
Diffstat (limited to 'server')
-rw-r--r--server/controllers/api/config.ts16
-rw-r--r--server/controllers/api/users/index.ts47
-rw-r--r--server/helpers/audit-logger.ts1
-rw-r--r--server/helpers/custom-validators/users.ts5
-rw-r--r--server/initializers/checker.ts3
-rw-r--r--server/initializers/constants.ts6
-rw-r--r--server/initializers/installer.ts1
-rw-r--r--server/initializers/migrations/0265-user-email-verified.ts24
-rw-r--r--server/lib/emailer.ts17
-rw-r--r--server/lib/oauth-model.ts5
-rw-r--r--server/lib/redis.ts18
-rw-r--r--server/middlewares/validators/users.ts46
-rw-r--r--server/models/account/user.ts8
-rw-r--r--server/tests/api/check-params/config.ts3
-rw-r--r--server/tests/api/check-params/users.ts22
-rw-r--r--server/tests/api/server/config.ts5
-rw-r--r--server/tests/api/server/email.ts42
-rw-r--r--server/tests/api/users/index.ts1
-rw-r--r--server/tests/api/users/users-verification.ts133
-rw-r--r--server/tests/api/users/users.ts2
-rw-r--r--server/tests/utils/server/config.ts3
-rw-r--r--server/tests/utils/users/users.ts26
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'
28import { usersAskResetPasswordValidator, usersBlockingValidator, usersResetPasswordValidator } from '../../../middlewares/validators' 28import {
29 usersAskResetPasswordValidator, usersBlockingValidator, usersResetPasswordValidator,
30 usersAskSendVerifyEmailValidator, usersVerifyEmailValidator
31} from '../../../middlewares/validators'
29import { UserModel } from '../../../models/account/user' 32import { UserModel } from '../../../models/account/user'
30import { OAuthTokenModel } from '../../../models/oauth/oauth-token' 33import { OAuthTokenModel } from '../../../models/oauth/oauth-token'
31import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger' 34import { 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
116usersRouter.post('/ask-send-verify-email',
117 loginRateLimiter,
118 asyncMiddleware(usersAskSendVerifyEmailValidator),
119 asyncMiddleware(askSendVerifyUserEmail)
120)
121
122usersRouter.post('/:id/verify-email',
123 asyncMiddleware(usersVerifyEmailValidator),
124 asyncMiddleware(verifyUserEmail)
125)
126
113usersRouter.post('/token', 127usersRouter.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
283async 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
290async 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
298async 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
264function success (req: express.Request, res: express.Response, next: express.NextFunction) { 307function 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
36function isUserEmailVerifiedValid (value: any) {
37 return isBooleanValid(value)
38}
39
36const nsfwPolicies = values(NSFW_POLICY_TYPES) 40const nsfwPolicies = values(NSFW_POLICY_TYPES)
37function isUserNSFWPolicyValid (value: any) { 41function 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
18const LAST_MIGRATION_VERSION = 260 18const 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
501const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes 502const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes
502 503
504const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
505
503const NSFW_POLICY_TYPES: { [ id: string]: NSFWPolicyType } = { 506const 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 @@
1import * as Sequelize from 'sequelize'
2
3async 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
20function down (options) {
21 throw new Error('Not implemented.')
22}
23
24export { 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'
3import { UserModel } from '../models/account/user' 3import { UserModel } from '../models/account/user'
4import { OAuthClientModel } from '../models/oauth/oauth-client' 4import { OAuthClientModel } from '../models/oauth/oauth-client'
5import { OAuthTokenModel } from '../models/oauth/oauth-token' 5import { OAuthTokenModel } from '../models/oauth/oauth-token'
6import { CONFIG } from '../initializers/constants'
6 7
7type TokenInfo = { accessToken: string, refreshToken: string, accessTokenExpiresAt: Date, refreshTokenExpiresAt: Date } 8type 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'
2import { createClient, RedisClient } from 'redis' 2import { createClient, RedisClient } from 'redis'
3import { logger } from '../helpers/logger' 3import { logger } from '../helpers/logger'
4import { generateRandomString } from '../helpers/utils' 4import { generateRandomString } from '../helpers/utils'
5import { CONFIG, USER_PASSWORD_RESET_LIFETIME, VIDEO_VIEW_LIFETIME } from '../initializers' 5import { CONFIG, USER_PASSWORD_RESET_LIFETIME, USER_EMAIL_VERIFY_LIFETIME, VIDEO_VIEW_LIFETIME } from '../initializers'
6 6
7type CachedRoute = { 7type 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
251const 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
269const 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
253export { 295export {
@@ -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'
5import { 5import {
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'
17import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index' 19import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index'
18import { mockSmtpServer } from '../../utils/miscs/email' 20import { 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 @@
1import './user-subscriptions' 1import './user-subscriptions'
2import './users' 2import './users'
3import './users-verification'
3import './users-multiple-servers' 4import './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
3import * as chai from 'chai'
4import 'mocha'
5import {
6 registerUser, flushTests, getUserInformation, getMyUserInformation, killallServers,
7 userLogin, login, runServer, ServerInfo, verifyEmail, updateCustomSubConfig
8} from '../../utils'
9import { setAccessTokensToServers } from '../../utils/users/login'
10import { mockSmtpServer } from '../../utils/miscs/email'
11import { waitJobs } from '../../utils/server/jobs'
12
13const expect = chai.expect
14
15describe('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'
12import { follow } from '../../utils/server/follows' 12import { follow } from '../../utils/server/follows'
13import { setAccessTokensToServers } from '../../utils/users/login' 13import { 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
249function 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
260function 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
251export { 273export {
@@ -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}