Handle email update on server
authorChocobozzz <me@florianbigard.com>
Tue, 11 Jun 2019 09:54:33 +0000 (11:54 +0200)
committerChocobozzz <me@florianbigard.com>
Tue, 11 Jun 2019 12:31:11 +0000 (14:31 +0200)
12 files changed:
.github/FUNDING.yml [new file with mode: 0644]
server/controllers/api/users/index.ts
server/controllers/api/users/me.ts
server/initializers/constants.ts
server/initializers/migrations/0390-user-pending-email.ts [new file with mode: 0644]
server/lib/user.ts
server/middlewares/validators/users.ts
server/models/account/user.ts
server/tests/api/server/email.ts
server/tests/api/users/users-verification.ts
shared/extra-utils/users/users.ts
shared/models/users/user.model.ts

diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644 (file)
index 0000000..cece657
--- /dev/null
@@ -0,0 +1 @@
+custom: https://framasoft.org/en/#soutenir
index 99f51a64831200228d601ebe082098ffda1418e8..c1d72087cc3a5508fa0f300f9c80c38c6b7b2231 100644 (file)
@@ -6,7 +6,7 @@ import { getFormattedObjects } from '../../../helpers/utils'
 import { RATES_LIMIT, WEBSERVER } from '../../../initializers/constants'
 import { Emailer } from '../../../lib/emailer'
 import { Redis } from '../../../lib/redis'
-import { createUserAccountAndChannelAndPlaylist } from '../../../lib/user'
+import { createUserAccountAndChannelAndPlaylist, sendVerifyUserEmail } from '../../../lib/user'
 import {
   asyncMiddleware,
   asyncRetryTransactionMiddleware,
@@ -147,7 +147,7 @@ usersRouter.post('/:id/reset-password',
 usersRouter.post('/ask-send-verify-email',
   askSendEmailLimiter,
   asyncMiddleware(usersAskSendVerifyEmailValidator),
-  asyncMiddleware(askSendVerifyUserEmail)
+  asyncMiddleware(reSendVerifyUserEmail)
 )
 
 usersRouter.post('/:id/verify-email',
@@ -320,14 +320,7 @@ async function resetUserPassword (req: express.Request, res: express.Response) {
   return res.status(204).end()
 }
 
-async function sendVerifyUserEmail (user: UserModel) {
-  const verificationString = await Redis.Instance.setVerifyEmailVerificationString(user.id)
-  const url = WEBSERVER.URL + '/verify-account/email?userId=' + user.id + '&verificationString=' + verificationString
-  await Emailer.Instance.addVerifyEmailJob(user.email, url)
-  return
-}
-
-async function askSendVerifyUserEmail (req: express.Request, res: express.Response) {
+async function reSendVerifyUserEmail (req: express.Request, res: express.Response) {
   const user = res.locals.user
 
   await sendVerifyUserEmail(user)
@@ -339,6 +332,11 @@ async function verifyUserEmail (req: express.Request, res: express.Response) {
   const user = res.locals.user
   user.emailVerified = true
 
+  if (req.body.isPendingEmail === true) {
+    user.email = user.pendingEmail
+    user.pendingEmail = null
+  }
+
   await user.save()
 
   return res.status(204).end()
index ddb239e7bdab4d2083ee1b40d35be94bf9004946..1750a02e9377fbcabcd98a712e7283a8bdf89921 100644 (file)
@@ -28,6 +28,7 @@ import { VideoImportModel } from '../../../models/video/video-import'
 import { AccountModel } from '../../../models/account/account'
 import { CONFIG } from '../../../initializers/config'
 import { sequelizeTypescript } from '../../../initializers/database'
+import { sendVerifyUserEmail } from '../../../lib/user'
 
 const auditLogger = auditLoggerFactory('users-me')
 
@@ -171,17 +172,26 @@ async function deleteMe (req: express.Request, res: express.Response) {
 
 async function updateMe (req: express.Request, res: express.Response) {
   const body: UserUpdateMe = req.body
+  let sendVerificationEmail = false
 
   const user = res.locals.oauth.token.user
   const oldUserAuditView = new UserAuditView(user.toFormattedJSON({}))
 
   if (body.password !== undefined) user.password = body.password
-  if (body.email !== undefined) user.email = body.email
   if (body.nsfwPolicy !== undefined) user.nsfwPolicy = body.nsfwPolicy
   if (body.webTorrentEnabled !== undefined) user.webTorrentEnabled = body.webTorrentEnabled
   if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo
   if (body.videosHistoryEnabled !== undefined) user.videosHistoryEnabled = body.videosHistoryEnabled
 
+  if (body.email !== undefined) {
+    if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
+      user.pendingEmail = body.email
+      sendVerificationEmail = true
+    } else {
+      user.email = body.email
+    }
+  }
+
   await sequelizeTypescript.transaction(async t => {
     const userAccount = await AccountModel.load(user.Account.id)
 
@@ -196,6 +206,10 @@ async function updateMe (req: express.Request, res: express.Response) {
     auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON({})), oldUserAuditView)
   })
 
+  if (sendVerificationEmail === true) {
+    await sendVerifyUserEmail(user, true)
+  }
+
   return res.sendStatus(204)
 }
 
index be30be463468c19ca1c31cb104b3cb16a4f62511..c2b8eff9539621fe24db1aa2d0bd0b83d9689b2c 100644 (file)
@@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
 
 // ---------------------------------------------------------------------------
 
-const LAST_MIGRATION_VERSION = 385
+const LAST_MIGRATION_VERSION = 390
 
 // ---------------------------------------------------------------------------
 
diff --git a/server/initializers/migrations/0390-user-pending-email.ts b/server/initializers/migrations/0390-user-pending-email.ts
new file mode 100644 (file)
index 0000000..5ca8717
--- /dev/null
@@ -0,0 +1,25 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction,
+  queryInterface: Sequelize.QueryInterface,
+  sequelize: Sequelize.Sequelize,
+  db: any
+}): Promise<void> {
+  const data = {
+    type: Sequelize.STRING(400),
+    allowNull: true,
+    defaultValue: null
+  }
+
+  await utils.queryInterface.addColumn('user', 'pendingEmail', data)
+}
+
+function down (options) {
+  throw new Error('Not implemented.')
+}
+
+export {
+  up,
+  down
+}
index b50b09d72a5a59f2762dd5622e5eea7429f710dd..0e40077707fc823a8719674a891437821e004def 100644 (file)
@@ -1,6 +1,6 @@
 import * as uuidv4 from 'uuid/v4'
 import { ActivityPubActorType } from '../../shared/models/activitypub'
-import { SERVER_ACTOR_NAME } from '../initializers/constants'
+import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants'
 import { AccountModel } from '../models/account/account'
 import { UserModel } from '../models/account/user'
 import { buildActorInstance, getAccountActivityPubUrl, setAsyncActorKeys } from './activitypub'
@@ -12,6 +12,8 @@ import { UserNotificationSetting, UserNotificationSettingValue } from '../../sha
 import { createWatchLaterPlaylist } from './video-playlist'
 import { sequelizeTypescript } from '../initializers/database'
 import { Transaction } from 'sequelize/types'
+import { Redis } from './redis'
+import { Emailer } from './emailer'
 
 type ChannelNames = { name: string, displayName: string }
 async function createUserAccountAndChannelAndPlaylist (parameters: {
@@ -100,12 +102,24 @@ async function createApplicationActor (applicationId: number) {
   return accountCreated
 }
 
+async function sendVerifyUserEmail (user: UserModel, isPendingEmail = false) {
+  const verificationString = await Redis.Instance.setVerifyEmailVerificationString(user.id)
+  let url = WEBSERVER.URL + '/verify-account/email?userId=' + user.id + '&verificationString=' + verificationString
+
+  if (isPendingEmail) url += '&isPendingEmail=true'
+
+  const email = isPendingEmail ? user.pendingEmail : user.email
+
+  await Emailer.Instance.addVerifyEmailJob(email, url)
+}
+
 // ---------------------------------------------------------------------------
 
 export {
   createApplicationActor,
   createUserAccountAndChannelAndPlaylist,
-  createLocalAccountWithoutKeys
+  createLocalAccountWithoutKeys,
+  sendVerifyUserEmail
 }
 
 // ---------------------------------------------------------------------------
index b4e09c9b7a794b05e14bc9ddf45f206558faca30..a4d4ae46d0a1f5980ec3ab41e602e811f969976f 100644 (file)
@@ -27,7 +27,6 @@ import { areValidationErrors } from './utils'
 import { ActorModel } from '../../models/activitypub/actor'
 import { isActorPreferredUsernameValid } from '../../helpers/custom-validators/activitypub/actor'
 import { isVideoChannelNameValid } from '../../helpers/custom-validators/video-channels'
-import { UserCreate } from '../../../shared/models/users'
 import { UserRegister } from '../../../shared/models/users/user-register.model'
 
 const usersAddValidator = [
@@ -178,13 +177,27 @@ const usersUpdateValidator = [
 ]
 
 const usersUpdateMeValidator = [
-  body('displayName').optional().custom(isUserDisplayNameValid).withMessage('Should have a valid display name'),
-  body('description').optional().custom(isUserDescriptionValid).withMessage('Should have a valid description'),
-  body('currentPassword').optional().custom(isUserPasswordValid).withMessage('Should have a valid current password'),
-  body('password').optional().custom(isUserPasswordValid).withMessage('Should have a valid password'),
-  body('email').optional().isEmail().withMessage('Should have a valid email attribute'),
-  body('nsfwPolicy').optional().custom(isUserNSFWPolicyValid).withMessage('Should have a valid display Not Safe For Work policy'),
-  body('autoPlayVideo').optional().custom(isUserAutoPlayVideoValid).withMessage('Should have a valid automatically plays video attribute'),
+  body('displayName')
+    .optional()
+    .custom(isUserDisplayNameValid).withMessage('Should have a valid display name'),
+  body('description')
+    .optional()
+    .custom(isUserDescriptionValid).withMessage('Should have a valid description'),
+  body('currentPassword')
+    .optional()
+    .custom(isUserPasswordValid).withMessage('Should have a valid current password'),
+  body('password')
+    .optional()
+    .custom(isUserPasswordValid).withMessage('Should have a valid password'),
+  body('email')
+    .optional()
+    .isEmail().withMessage('Should have a valid email attribute'),
+  body('nsfwPolicy')
+    .optional()
+    .custom(isUserNSFWPolicyValid).withMessage('Should have a valid display Not Safe For Work policy'),
+  body('autoPlayVideo')
+    .optional()
+    .custom(isUserAutoPlayVideoValid).withMessage('Should have a valid automatically plays video attribute'),
   body('videosHistoryEnabled')
     .optional()
     .custom(isUserVideosHistoryEnabledValid).withMessage('Should have a valid videos history enabled attribute'),
@@ -329,8 +342,14 @@ const usersAskSendVerifyEmailValidator = [
 ]
 
 const usersVerifyEmailValidator = [
-  param('id').isInt().not().isEmpty().withMessage('Should have a valid id'),
-  body('verificationString').not().isEmpty().withMessage('Should have a valid verification string'),
+  param('id')
+    .isInt().not().isEmpty().withMessage('Should have a valid id'),
+
+  body('verificationString')
+    .not().isEmpty().withMessage('Should have a valid verification string'),
+  body('isPendingEmail')
+    .optional()
+    .toBoolean(),
 
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
     logger.debug('Checking usersVerifyEmail parameters', { parameters: req.params })
index 4a9acd703a94e2615229fe1cb8ee0fbf1d65d164..e7503952138402d67b52e2ed56399dcf3e643bb6 100644 (file)
@@ -113,6 +113,11 @@ export class UserModel extends Model<UserModel> {
   @Column(DataType.STRING(400))
   email: string
 
+  @AllowNull(true)
+  @IsEmail
+  @Column(DataType.STRING(400))
+  pendingEmail: string
+
   @AllowNull(true)
   @Default(null)
   @Is('UserEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true))
@@ -540,6 +545,7 @@ export class UserModel extends Model<UserModel> {
       id: this.id,
       username: this.username,
       email: this.email,
+      pendingEmail: this.pendingEmail,
       emailVerified: this.emailVerified,
       nsfwPolicy: this.nsfwPolicy,
       webTorrentEnabled: this.webTorrentEnabled,
index 5929a3adbe6fc004f6d4da0420abb6e356d0a927..7b7acfd12a161f31b71590dccd64ccc45dfb2880 100644 (file)
@@ -250,7 +250,7 @@ describe('Test emails', function () {
     })
 
     it('Should not verify the email with an invalid verification string', async function () {
-      await verifyEmail(server.url, userId, verificationString + 'b', 403)
+      await verifyEmail(server.url, userId, verificationString + 'b', false, 403)
     })
 
     it('Should verify the email', async function () {
index 3b37a26cfe2721a85aba227e1e34f4636da89d6b..b8fa1430b1a6dd75dddb05a483f9ed86fa56b7d7 100644 (file)
@@ -3,18 +3,29 @@
 import * as chai from 'chai'
 import 'mocha'
 import {
-  registerUser, flushTests, getUserInformation, getMyUserInformation, killallServers,
-  userLogin, login, flushAndRunServer, ServerInfo, verifyEmail, updateCustomSubConfig, wait, cleanupTests
+  cleanupTests,
+  flushAndRunServer,
+  getMyUserInformation,
+  getUserInformation,
+  login,
+  registerUser,
+  ServerInfo,
+  updateCustomSubConfig,
+  updateMyUser,
+  userLogin,
+  verifyEmail
 } from '../../../../shared/extra-utils'
 import { setAccessTokensToServers } from '../../../../shared/extra-utils/users/login'
 import { MockSmtpServer } from '../../../../shared/extra-utils/miscs/email'
 import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
+import { User } from '../../../../shared/models/users'
 
 const expect = chai.expect
 
 describe('Test users account verification', function () {
   let server: ServerInfo
   let userId: number
+  let userAccessToken: string
   let verificationString: string
   let expectedEmailsLength = 0
   const user1 = {
@@ -83,11 +94,53 @@ describe('Test users account verification', function () {
 
   it('Should verify the user via email and allow login', async function () {
     await verifyEmail(server.url, userId, verificationString)
-    await login(server.url, server.client, user1)
+
+    const res = await login(server.url, server.client, user1)
+    userAccessToken = res.body.access_token
+
     const resUserVerified = await getUserInformation(server.url, server.accessToken, userId)
     expect(resUserVerified.body.emailVerified).to.be.true
   })
 
+  it('Should be able to change the user email', async function () {
+    let updateVerificationString: string
+
+    {
+      await updateMyUser({
+        url: server.url,
+        accessToken: userAccessToken,
+        email: 'updated@example.com'
+      })
+
+      await waitJobs(server)
+      expectedEmailsLength++
+      expect(emails).to.have.lengthOf(expectedEmailsLength)
+
+      const email = emails[expectedEmailsLength - 1]
+
+      const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text'])
+      updateVerificationString = verificationStringMatches[1]
+    }
+
+    {
+      const res = await getMyUserInformation(server.url, userAccessToken)
+      const me: User = res.body
+
+      expect(me.email).to.equal('user_1@example.com')
+      expect(me.pendingEmail).to.equal('updated@example.com')
+    }
+
+    {
+      await verifyEmail(server.url, userId, updateVerificationString, true)
+
+      const res = await getMyUserInformation(server.url, userAccessToken)
+      const me: User = res.body
+
+      expect(me.email).to.equal('updated@example.com')
+      expect(me.pendingEmail).to.be.null
+    }
+  })
+
   it('Should register user not requiring email verification if setting not enabled', async function () {
     this.timeout(5000)
     await updateCustomSubConfig(server.url, server.accessToken, {
index c09211b71ab43c5a5520ded9e979d78e1d4ff5c9..1c39881d65183c3758d2d503b176584f7f855a4f 100644 (file)
@@ -323,13 +323,16 @@ function askSendVerifyEmail (url: string, email: string) {
   })
 }
 
-function verifyEmail (url: string, userId: number, verificationString: string, statusCodeExpected = 204) {
+function verifyEmail (url: string, userId: number, verificationString: string, isPendingEmail = false, statusCodeExpected = 204) {
   const path = '/api/v1/users/' + userId + '/verify-email'
 
   return makePostBodyRequest({
     url,
     path,
-    fields: { verificationString },
+    fields: {
+      verificationString,
+      isPendingEmail
+    },
     statusCodeExpected
   })
 }
index 2f6a3c71982a846ef8ee9ebc552bc991e5f883fb..b5823b47a65af08c55009fea87204821487f8161 100644 (file)
@@ -9,6 +9,7 @@ export interface User {
   id: number
   username: string
   email: string
+  pendingEmail: string | null
   emailVerified: boolean
   nsfwPolicy: NSFWPolicyType