aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.github/FUNDING.yml1
-rw-r--r--server/controllers/api/users/index.ts18
-rw-r--r--server/controllers/api/users/me.ts16
-rw-r--r--server/initializers/constants.ts2
-rw-r--r--server/initializers/migrations/0390-user-pending-email.ts25
-rw-r--r--server/lib/user.ts18
-rw-r--r--server/middlewares/validators/users.ts39
-rw-r--r--server/models/account/user.ts6
-rw-r--r--server/tests/api/server/email.ts2
-rw-r--r--server/tests/api/users/users-verification.ts59
-rw-r--r--shared/extra-utils/users/users.ts7
-rw-r--r--shared/models/users/user.model.ts1
12 files changed, 164 insertions, 30 deletions
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 000000000..cece65761
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
custom: https://framasoft.org/en/#soutenir
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
index 99f51a648..c1d72087c 100644
--- a/server/controllers/api/users/index.ts
+++ b/server/controllers/api/users/index.ts
@@ -6,7 +6,7 @@ import { getFormattedObjects } from '../../../helpers/utils'
6import { RATES_LIMIT, WEBSERVER } from '../../../initializers/constants' 6import { RATES_LIMIT, WEBSERVER } from '../../../initializers/constants'
7import { Emailer } from '../../../lib/emailer' 7import { Emailer } from '../../../lib/emailer'
8import { Redis } from '../../../lib/redis' 8import { Redis } from '../../../lib/redis'
9import { createUserAccountAndChannelAndPlaylist } from '../../../lib/user' 9import { createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user'
10import { 10import {
11 asyncMiddleware, 11 asyncMiddleware,
12 asyncRetryTransactionMiddleware, 12 asyncRetryTransactionMiddleware,
@@ -147,7 +147,7 @@ usersRouter.post('/:id/reset-password',
147usersRouter.post('/ask-send-verify-email', 147usersRouter.post('/ask-send-verify-email',
148 askSendEmailLimiter, 148 askSendEmailLimiter,
149 asyncMiddleware(usersAskSendVerifyEmailValidator), 149 asyncMiddleware(usersAskSendVerifyEmailValidator),
150 asyncMiddleware(askSendVerifyUserEmail) 150 asyncMiddleware(reSendVerifyUserEmail)
151) 151)
152 152
153usersRouter.post('/:id/verify-email', 153usersRouter.post('/:id/verify-email',
@@ -320,14 +320,7 @@ async function resetUserPassword (req: express.Request, res: express.Response) {
320 return res.status(204).end() 320 return res.status(204).end()
321} 321}
322 322
323async function sendVerifyUserEmail (user: UserModel) { 323async function reSendVerifyUserEmail (req: express.Request, res: express.Response) {
324 const verificationString = await Redis.Instance.setVerifyEmailVerificationString(user.id)
325 const url = WEBSERVER.URL + '/verify-account/email?userId=' + user.id + '&verificationString=' + verificationString
326 await Emailer.Instance.addVerifyEmailJob(user.email, url)
327 return
328}
329
330async function askSendVerifyUserEmail (req: express.Request, res: express.Response) {
331 const user = res.locals.user 324 const user = res.locals.user
332 325
333 await sendVerifyUserEmail(user) 326 await sendVerifyUserEmail(user)
@@ -339,6 +332,11 @@ async function verifyUserEmail (req: express.Request, res: express.Response) {
339 const user = res.locals.user 332 const user = res.locals.user
340 user.emailVerified = true 333 user.emailVerified = true
341 334
335 if (req.body.isPendingEmail === true) {
336 user.email = user.pendingEmail
337 user.pendingEmail = null
338 }
339
342 await user.save() 340 await user.save()
343 341
344 return res.status(204).end() 342 return res.status(204).end()
diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts
index ddb239e7b..1750a02e9 100644
--- a/server/controllers/api/users/me.ts
+++ b/server/controllers/api/users/me.ts
@@ -28,6 +28,7 @@ import { VideoImportModel } from '../../../models/video/video-import'
28import { AccountModel } from '../../../models/account/account' 28import { AccountModel } from '../../../models/account/account'
29import { CONFIG } from '../../../initializers/config' 29import { CONFIG } from '../../../initializers/config'
30import { sequelizeTypescript } from '../../../initializers/database' 30import { sequelizeTypescript } from '../../../initializers/database'
31import { sendVerifyUserEmail } from '../../../lib/user'
31 32
32const auditLogger = auditLoggerFactory('users-me') 33const auditLogger = auditLoggerFactory('users-me')
33 34
@@ -171,17 +172,26 @@ async function deleteMe (req: express.Request, res: express.Response) {
171 172
172async function updateMe (req: express.Request, res: express.Response) { 173async function updateMe (req: express.Request, res: express.Response) {
173 const body: UserUpdateMe = req.body 174 const body: UserUpdateMe = req.body
175 let sendVerificationEmail = false
174 176
175 const user = res.locals.oauth.token.user 177 const user = res.locals.oauth.token.user
176 const oldUserAuditView = new UserAuditView(user.toFormattedJSON({})) 178 const oldUserAuditView = new UserAuditView(user.toFormattedJSON({}))
177 179
178 if (body.password !== undefined) user.password = body.password 180 if (body.password !== undefined) user.password = body.password
179 if (body.email !== undefined) user.email = body.email
180 if (body.nsfwPolicy !== undefined) user.nsfwPolicy = body.nsfwPolicy 181 if (body.nsfwPolicy !== undefined) user.nsfwPolicy = body.nsfwPolicy
181 if (body.webTorrentEnabled !== undefined) user.webTorrentEnabled = body.webTorrentEnabled 182 if (body.webTorrentEnabled !== undefined) user.webTorrentEnabled = body.webTorrentEnabled
182 if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo 183 if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo
183 if (body.videosHistoryEnabled !== undefined) user.videosHistoryEnabled = body.videosHistoryEnabled 184 if (body.videosHistoryEnabled !== undefined) user.videosHistoryEnabled = body.videosHistoryEnabled
184 185
186 if (body.email !== undefined) {
187 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
188 user.pendingEmail = body.email
189 sendVerificationEmail = true
190 } else {
191 user.email = body.email
192 }
193 }
194
185 await sequelizeTypescript.transaction(async t => { 195 await sequelizeTypescript.transaction(async t => {
186 const userAccount = await AccountModel.load(user.Account.id) 196 const userAccount = await AccountModel.load(user.Account.id)
187 197
@@ -196,6 +206,10 @@ async function updateMe (req: express.Request, res: express.Response) {
196 auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON({})), oldUserAuditView) 206 auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON({})), oldUserAuditView)
197 }) 207 })
198 208
209 if (sendVerificationEmail === true) {
210 await sendVerifyUserEmail(user, true)
211 }
212
199 return res.sendStatus(204) 213 return res.sendStatus(204)
200} 214}
201 215
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index be30be463..c2b8eff95 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
14 14
15// --------------------------------------------------------------------------- 15// ---------------------------------------------------------------------------
16 16
17const LAST_MIGRATION_VERSION = 385 17const LAST_MIGRATION_VERSION = 390
18 18
19// --------------------------------------------------------------------------- 19// ---------------------------------------------------------------------------
20 20
diff --git a/server/initializers/migrations/0390-user-pending-email.ts b/server/initializers/migrations/0390-user-pending-email.ts
new file mode 100644
index 000000000..5ca871746
--- /dev/null
+++ b/server/initializers/migrations/0390-user-pending-email.ts
@@ -0,0 +1,25 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction,
5 queryInterface: Sequelize.QueryInterface,
6 sequelize: Sequelize.Sequelize,
7 db: any
8}): Promise<void> {
9 const data = {
10 type: Sequelize.STRING(400),
11 allowNull: true,
12 defaultValue: null
13 }
14
15 await utils.queryInterface.addColumn('user', 'pendingEmail', data)
16}
17
18function down (options) {
19 throw new Error('Not implemented.')
20}
21
22export {
23 up,
24 down
25}
diff --git a/server/lib/user.ts b/server/lib/user.ts
index b50b09d72..0e4007770 100644
--- a/server/lib/user.ts
+++ b/server/lib/user.ts
@@ -1,6 +1,6 @@
1import * as uuidv4 from 'uuid/v4' 1import * as uuidv4 from 'uuid/v4'
2import { ActivityPubActorType } from '../../shared/models/activitypub' 2import { ActivityPubActorType } from '../../shared/models/activitypub'
3import { SERVER_ACTOR_NAME } from '../initializers/constants' 3import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants'
4import { AccountModel } from '../models/account/account' 4import { AccountModel } from '../models/account/account'
5import { UserModel } from '../models/account/user' 5import { UserModel } from '../models/account/user'
6import { buildActorInstance, getAccountActivityPubUrl, setAsyncActorKeys } from './activitypub' 6import { buildActorInstance, getAccountActivityPubUrl, setAsyncActorKeys } from './activitypub'
@@ -12,6 +12,8 @@ import { UserNotificationSetting, UserNotificationSettingValue } from '../../sha
12import { createWatchLaterPlaylist } from './video-playlist' 12import { createWatchLaterPlaylist } from './video-playlist'
13import { sequelizeTypescript } from '../initializers/database' 13import { sequelizeTypescript } from '../initializers/database'
14import { Transaction } from 'sequelize/types' 14import { Transaction } from 'sequelize/types'
15import { Redis } from './redis'
16import { Emailer } from './emailer'
15 17
16type ChannelNames = { name: string, displayName: string } 18type ChannelNames = { name: string, displayName: string }
17async function createUserAccountAndChannelAndPlaylist (parameters: { 19async function createUserAccountAndChannelAndPlaylist (parameters: {
@@ -100,12 +102,24 @@ async function createApplicationActor (applicationId: number) {
100 return accountCreated 102 return accountCreated
101} 103}
102 104
105async function sendVerifyUserEmail (user: UserModel, isPendingEmail = false) {
106 const verificationString = await Redis.Instance.setVerifyEmailVerificationString(user.id)
107 let url = WEBSERVER.URL + '/verify-account/email?userId=' + user.id + '&verificationString=' + verificationString
108
109 if (isPendingEmail) url += '&isPendingEmail=true'
110
111 const email = isPendingEmail ? user.pendingEmail : user.email
112
113 await Emailer.Instance.addVerifyEmailJob(email, url)
114}
115
103// --------------------------------------------------------------------------- 116// ---------------------------------------------------------------------------
104 117
105export { 118export {
106 createApplicationActor, 119 createApplicationActor,
107 createUserAccountAndChannelAndPlaylist, 120 createUserAccountAndChannelAndPlaylist,
108 createLocalAccountWithoutKeys 121 createLocalAccountWithoutKeys,
122 sendVerifyUserEmail
109} 123}
110 124
111// --------------------------------------------------------------------------- 125// ---------------------------------------------------------------------------
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index b4e09c9b7..a4d4ae46d 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -27,7 +27,6 @@ import { areValidationErrors } from './utils'
27import { ActorModel } from '../../models/activitypub/actor' 27import { ActorModel } from '../../models/activitypub/actor'
28import { isActorPreferredUsernameValid } from '../../helpers/custom-validators/activitypub/actor' 28import { isActorPreferredUsernameValid } from '../../helpers/custom-validators/activitypub/actor'
29import { isVideoChannelNameValid } from '../../helpers/custom-validators/video-channels' 29import { isVideoChannelNameValid } from '../../helpers/custom-validators/video-channels'
30import { UserCreate } from '../../../shared/models/users'
31import { UserRegister } from '../../../shared/models/users/user-register.model' 30import { UserRegister } from '../../../shared/models/users/user-register.model'
32 31
33const usersAddValidator = [ 32const usersAddValidator = [
@@ -178,13 +177,27 @@ const usersUpdateValidator = [
178] 177]
179 178
180const usersUpdateMeValidator = [ 179const usersUpdateMeValidator = [
181 body('displayName').optional().custom(isUserDisplayNameValid).withMessage('Should have a valid display name'), 180 body('displayName')
182 body('description').optional().custom(isUserDescriptionValid).withMessage('Should have a valid description'), 181 .optional()
183 body('currentPassword').optional().custom(isUserPasswordValid).withMessage('Should have a valid current password'), 182 .custom(isUserDisplayNameValid).withMessage('Should have a valid display name'),
184 body('password').optional().custom(isUserPasswordValid).withMessage('Should have a valid password'), 183 body('description')
185 body('email').optional().isEmail().withMessage('Should have a valid email attribute'), 184 .optional()
186 body('nsfwPolicy').optional().custom(isUserNSFWPolicyValid).withMessage('Should have a valid display Not Safe For Work policy'), 185 .custom(isUserDescriptionValid).withMessage('Should have a valid description'),
187 body('autoPlayVideo').optional().custom(isUserAutoPlayVideoValid).withMessage('Should have a valid automatically plays video attribute'), 186 body('currentPassword')
187 .optional()
188 .custom(isUserPasswordValid).withMessage('Should have a valid current password'),
189 body('password')
190 .optional()
191 .custom(isUserPasswordValid).withMessage('Should have a valid password'),
192 body('email')
193 .optional()
194 .isEmail().withMessage('Should have a valid email attribute'),
195 body('nsfwPolicy')
196 .optional()
197 .custom(isUserNSFWPolicyValid).withMessage('Should have a valid display Not Safe For Work policy'),
198 body('autoPlayVideo')
199 .optional()
200 .custom(isUserAutoPlayVideoValid).withMessage('Should have a valid automatically plays video attribute'),
188 body('videosHistoryEnabled') 201 body('videosHistoryEnabled')
189 .optional() 202 .optional()
190 .custom(isUserVideosHistoryEnabledValid).withMessage('Should have a valid videos history enabled attribute'), 203 .custom(isUserVideosHistoryEnabledValid).withMessage('Should have a valid videos history enabled attribute'),
@@ -329,8 +342,14 @@ const usersAskSendVerifyEmailValidator = [
329] 342]
330 343
331const usersVerifyEmailValidator = [ 344const usersVerifyEmailValidator = [
332 param('id').isInt().not().isEmpty().withMessage('Should have a valid id'), 345 param('id')
333 body('verificationString').not().isEmpty().withMessage('Should have a valid verification string'), 346 .isInt().not().isEmpty().withMessage('Should have a valid id'),
347
348 body('verificationString')
349 .not().isEmpty().withMessage('Should have a valid verification string'),
350 body('isPendingEmail')
351 .optional()
352 .toBoolean(),
334 353
335 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 354 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
336 logger.debug('Checking usersVerifyEmail parameters', { parameters: req.params }) 355 logger.debug('Checking usersVerifyEmail parameters', { parameters: req.params })
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index 4a9acd703..e75039521 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -114,6 +114,11 @@ export class UserModel extends Model<UserModel> {
114 email: string 114 email: string
115 115
116 @AllowNull(true) 116 @AllowNull(true)
117 @IsEmail
118 @Column(DataType.STRING(400))
119 pendingEmail: string
120
121 @AllowNull(true)
117 @Default(null) 122 @Default(null)
118 @Is('UserEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true)) 123 @Is('UserEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true))
119 @Column 124 @Column
@@ -540,6 +545,7 @@ export class UserModel extends Model<UserModel> {
540 id: this.id, 545 id: this.id,
541 username: this.username, 546 username: this.username,
542 email: this.email, 547 email: this.email,
548 pendingEmail: this.pendingEmail,
543 emailVerified: this.emailVerified, 549 emailVerified: this.emailVerified,
544 nsfwPolicy: this.nsfwPolicy, 550 nsfwPolicy: this.nsfwPolicy,
545 webTorrentEnabled: this.webTorrentEnabled, 551 webTorrentEnabled: this.webTorrentEnabled,
diff --git a/server/tests/api/server/email.ts b/server/tests/api/server/email.ts
index 5929a3adb..7b7acfd12 100644
--- a/server/tests/api/server/email.ts
+++ b/server/tests/api/server/email.ts
@@ -250,7 +250,7 @@ describe('Test emails', function () {
250 }) 250 })
251 251
252 it('Should not verify the email with an invalid verification string', async function () { 252 it('Should not verify the email with an invalid verification string', async function () {
253 await verifyEmail(server.url, userId, verificationString + 'b', 403) 253 await verifyEmail(server.url, userId, verificationString + 'b', false, 403)
254 }) 254 })
255 255
256 it('Should verify the email', async function () { 256 it('Should verify the email', async function () {
diff --git a/server/tests/api/users/users-verification.ts b/server/tests/api/users/users-verification.ts
index 3b37a26cf..b8fa1430b 100644
--- a/server/tests/api/users/users-verification.ts
+++ b/server/tests/api/users/users-verification.ts
@@ -3,18 +3,29 @@
3import * as chai from 'chai' 3import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { 5import {
6 registerUser, flushTests, getUserInformation, getMyUserInformation, killallServers, 6 cleanupTests,
7 userLogin, login, flushAndRunServer, ServerInfo, verifyEmail, updateCustomSubConfig, wait, cleanupTests 7 flushAndRunServer,
8 getMyUserInformation,
9 getUserInformation,
10 login,
11 registerUser,
12 ServerInfo,
13 updateCustomSubConfig,
14 updateMyUser,
15 userLogin,
16 verifyEmail
8} from '../../../../shared/extra-utils' 17} from '../../../../shared/extra-utils'
9import { setAccessTokensToServers } from '../../../../shared/extra-utils/users/login' 18import { setAccessTokensToServers } from '../../../../shared/extra-utils/users/login'
10import { MockSmtpServer } from '../../../../shared/extra-utils/miscs/email' 19import { MockSmtpServer } from '../../../../shared/extra-utils/miscs/email'
11import { waitJobs } from '../../../../shared/extra-utils/server/jobs' 20import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
21import { User } from '../../../../shared/models/users'
12 22
13const expect = chai.expect 23const expect = chai.expect
14 24
15describe('Test users account verification', function () { 25describe('Test users account verification', function () {
16 let server: ServerInfo 26 let server: ServerInfo
17 let userId: number 27 let userId: number
28 let userAccessToken: string
18 let verificationString: string 29 let verificationString: string
19 let expectedEmailsLength = 0 30 let expectedEmailsLength = 0
20 const user1 = { 31 const user1 = {
@@ -83,11 +94,53 @@ describe('Test users account verification', function () {
83 94
84 it('Should verify the user via email and allow login', async function () { 95 it('Should verify the user via email and allow login', async function () {
85 await verifyEmail(server.url, userId, verificationString) 96 await verifyEmail(server.url, userId, verificationString)
86 await login(server.url, server.client, user1) 97
98 const res = await login(server.url, server.client, user1)
99 userAccessToken = res.body.access_token
100
87 const resUserVerified = await getUserInformation(server.url, server.accessToken, userId) 101 const resUserVerified = await getUserInformation(server.url, server.accessToken, userId)
88 expect(resUserVerified.body.emailVerified).to.be.true 102 expect(resUserVerified.body.emailVerified).to.be.true
89 }) 103 })
90 104
105 it('Should be able to change the user email', async function () {
106 let updateVerificationString: string
107
108 {
109 await updateMyUser({
110 url: server.url,
111 accessToken: userAccessToken,
112 email: 'updated@example.com'
113 })
114
115 await waitJobs(server)
116 expectedEmailsLength++
117 expect(emails).to.have.lengthOf(expectedEmailsLength)
118
119 const email = emails[expectedEmailsLength - 1]
120
121 const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text'])
122 updateVerificationString = verificationStringMatches[1]
123 }
124
125 {
126 const res = await getMyUserInformation(server.url, userAccessToken)
127 const me: User = res.body
128
129 expect(me.email).to.equal('user_1@example.com')
130 expect(me.pendingEmail).to.equal('updated@example.com')
131 }
132
133 {
134 await verifyEmail(server.url, userId, updateVerificationString, true)
135
136 const res = await getMyUserInformation(server.url, userAccessToken)
137 const me: User = res.body
138
139 expect(me.email).to.equal('updated@example.com')
140 expect(me.pendingEmail).to.be.null
141 }
142 })
143
91 it('Should register user not requiring email verification if setting not enabled', async function () { 144 it('Should register user not requiring email verification if setting not enabled', async function () {
92 this.timeout(5000) 145 this.timeout(5000)
93 await updateCustomSubConfig(server.url, server.accessToken, { 146 await updateCustomSubConfig(server.url, server.accessToken, {
diff --git a/shared/extra-utils/users/users.ts b/shared/extra-utils/users/users.ts
index c09211b71..1c39881d6 100644
--- a/shared/extra-utils/users/users.ts
+++ b/shared/extra-utils/users/users.ts
@@ -323,13 +323,16 @@ function askSendVerifyEmail (url: string, email: string) {
323 }) 323 })
324} 324}
325 325
326function verifyEmail (url: string, userId: number, verificationString: string, statusCodeExpected = 204) { 326function verifyEmail (url: string, userId: number, verificationString: string, isPendingEmail = false, statusCodeExpected = 204) {
327 const path = '/api/v1/users/' + userId + '/verify-email' 327 const path = '/api/v1/users/' + userId + '/verify-email'
328 328
329 return makePostBodyRequest({ 329 return makePostBodyRequest({
330 url, 330 url,
331 path, 331 path,
332 fields: { verificationString }, 332 fields: {
333 verificationString,
334 isPendingEmail
335 },
333 statusCodeExpected 336 statusCodeExpected
334 }) 337 })
335} 338}
diff --git a/shared/models/users/user.model.ts b/shared/models/users/user.model.ts
index 2f6a3c719..b5823b47a 100644
--- a/shared/models/users/user.model.ts
+++ b/shared/models/users/user.model.ts
@@ -9,6 +9,7 @@ export interface User {
9 id: number 9 id: number
10 username: string 10 username: string
11 email: string 11 email: string
12 pendingEmail: string | null
12 emailVerified: boolean 13 emailVerified: boolean
13 nsfwPolicy: NSFWPolicyType 14 nsfwPolicy: NSFWPolicyType
14 15