Creating a user with an empty password will send an email to let him set his password...
authorJohn Livingston <38844060+JohnXLivingston@users.noreply.github.com>
Mon, 17 Feb 2020 09:16:52 +0000 (10:16 +0100)
committerGitHub <noreply@github.com>
Mon, 17 Feb 2020 09:16:52 +0000 (10:16 +0100)
* Creating a user with an empty password will send an email to let him set his password

* Consideration of Chocobozzz's comments

* Tips for optional password

* API documentation

* Fix circular imports

* Tests

14 files changed:
client/src/app/+admin/users/user-edit/user-create.component.ts
client/src/app/+admin/users/user-edit/user-edit.component.html
client/src/app/+admin/users/user-edit/user-update.component.ts
client/src/app/+admin/users/users.routes.ts
client/src/app/shared/forms/form-validators/user-validators.service.ts
server/controllers/api/users/index.ts
server/helpers/custom-validators/users.ts
server/initializers/constants.ts
server/lib/emailer.ts
server/lib/redis.ts
server/middlewares/validators/users.ts
server/tests/api/check-params/users.ts
server/tests/api/server/email.ts
support/doc/api/openapi.yaml

index e726ec4d7f75232a66b38653676964e268f5f7ef..1769c0de0c3a117e6eab3c9362efe87d4d088197 100644 (file)
@@ -1,5 +1,5 @@
 import { Component, OnInit } from '@angular/core'
-import { Router } from '@angular/router'
+import { Router, ActivatedRoute } from '@angular/router'
 import { AuthService, Notifier, ServerService } from '@app/core'
 import { UserCreate, UserRole } from '../../../../../../shared'
 import { UserEdit } from './user-edit'
@@ -23,6 +23,7 @@ export class UserCreateComponent extends UserEdit implements OnInit {
     protected configService: ConfigService,
     protected auth: AuthService,
     private userValidatorsService: UserValidatorsService,
+    private route: ActivatedRoute,
     private router: Router,
     private notifier: Notifier,
     private userService: UserService,
@@ -45,7 +46,7 @@ export class UserCreateComponent extends UserEdit implements OnInit {
     this.buildForm({
       username: this.userValidatorsService.USER_USERNAME,
       email: this.userValidatorsService.USER_EMAIL,
-      password: this.userValidatorsService.USER_PASSWORD,
+      password: this.isPasswordOptional() ? this.userValidatorsService.USER_PASSWORD_OPTIONAL : this.userValidatorsService.USER_PASSWORD,
       role: this.userValidatorsService.USER_ROLE,
       videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA,
       videoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY,
@@ -78,6 +79,11 @@ export class UserCreateComponent extends UserEdit implements OnInit {
     return true
   }
 
+  isPasswordOptional () {
+    const serverConfig = this.route.snapshot.data.serverConfig
+    return serverConfig.email.enabled
+  }
+
   getFormButtonTitle () {
     return this.i18n('Create user')
   }
index 4ff4d0d1228ac5b7dfdc5ce54e2cc2eb4dc041a8..2aca5ddca150a4afa108de19efd69a3ebc347c0d 100644 (file)
 
   <div class="form-group" *ngIf="isCreation()">
     <label i18n for="password">Password</label>
+    <my-help *ngIf="isPasswordOptional()">
+      <ng-template ptTemplate="customHtml">
+        <ng-container i18n>
+          If you leave the password empty, an email will be sent to the user.
+        </ng-container>
+      </ng-template>
+    </my-help>
     <input
       type="password" id="password" autocomplete="new-password"
       formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
index d1682a99df4e51ee2a059a8ad20418c36594bbc9..1ab2e9dbfec7e88f3ff1dbe817d4ada37a1eb342 100644 (file)
@@ -92,6 +92,10 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
     return false
   }
 
+  isPasswordOptional () {
+    return false
+  }
+
   getFormButtonTitle () {
     return this.i18n('Update user')
   }
index 8b3791bd3fb21bc16dbc25132721b97e159874f7..2d4f9305e8d0b173fd15c2cd1877265139fe1b10 100644 (file)
@@ -5,6 +5,7 @@ import { UserRight } from '../../../../../shared'
 import { UsersComponent } from './users.component'
 import { UserCreateComponent, UserUpdateComponent } from './user-edit'
 import { UserListComponent } from './user-list'
+import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service'
 
 export const UsersRoutes: Routes = [
   {
@@ -36,6 +37,9 @@ export const UsersRoutes: Routes = [
           meta: {
             title: 'Create a user'
           }
+        },
+        resolve: {
+          serverConfig: ServerConfigResolver
         }
       },
       {
index 4dff3e422067f0720635bafd26c1786e7bf02bc6..13b9228d43db21594115b3140412ee53b3bc6d39 100644 (file)
@@ -8,6 +8,7 @@ export class UserValidatorsService {
   readonly USER_USERNAME: BuildFormValidator
   readonly USER_EMAIL: BuildFormValidator
   readonly USER_PASSWORD: BuildFormValidator
+  readonly USER_PASSWORD_OPTIONAL: BuildFormValidator
   readonly USER_CONFIRM_PASSWORD: BuildFormValidator
   readonly USER_VIDEO_QUOTA: BuildFormValidator
   readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator
@@ -56,6 +57,17 @@ export class UserValidatorsService {
       }
     }
 
+    this.USER_PASSWORD_OPTIONAL = {
+      VALIDATORS: [
+        Validators.minLength(6),
+        Validators.maxLength(255)
+      ],
+      MESSAGES: {
+        'minlength': this.i18n('Password must be at least 6 characters long.'),
+        'maxlength': this.i18n('Password cannot be more than 255 characters long.')
+      }
+    }
+
     this.USER_CONFIRM_PASSWORD = {
       VALIDATORS: [],
       MESSAGES: {
index 0b70125370c34d1b758afdd899c1817c0d853906..98eb2beedaf34581b55cbc18e2817484842b821b 100644 (file)
@@ -2,7 +2,7 @@ import * as express from 'express'
 import * as RateLimit from 'express-rate-limit'
 import { UserCreate, UserRight, UserRole, UserUpdate } from '../../../../shared'
 import { logger } from '../../../helpers/logger'
-import { getFormattedObjects } from '../../../helpers/utils'
+import { generateRandomString, getFormattedObjects } from '../../../helpers/utils'
 import { WEBSERVER } from '../../../initializers/constants'
 import { Emailer } from '../../../lib/emailer'
 import { Redis } from '../../../lib/redis'
@@ -197,11 +197,25 @@ async function createUser (req: express.Request, res: express.Response) {
     adminFlags: body.adminFlags || UserAdminFlag.NONE
   }) as MUser
 
+  // NB: due to the validator usersAddValidator, password==='' can only be true if we can send the mail.
+  const createPassword = userToCreate.password === ''
+  if (createPassword) {
+    userToCreate.password = await generateRandomString(20)
+  }
+
   const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({ userToCreate: userToCreate })
 
   auditLogger.create(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()))
   logger.info('User %s with its channel and account created.', body.username)
 
+  if (createPassword) {
+    // this will send an email for newly created users, so then can set their first password.
+    logger.info('Sending to user %s a create password email', body.username)
+    const verificationString = await Redis.Instance.setCreatePasswordVerificationString(user.id)
+    const url = WEBSERVER.URL + '/reset-password?userId=' + user.id + '&verificationString=' + verificationString
+    await Emailer.Instance.addPasswordCreateEmailJob(userToCreate.username, user.email, url)
+  }
+
   Hooks.runAction('action:api.user.created', { body, user, account, videoChannel })
 
   return res.json({
index b4d5751e70281d0ba6f7941fbb6bbda564bb5744..63673bee217e2b07616a1824840cc31f6022b450 100644 (file)
@@ -3,6 +3,7 @@ import { UserRole } from '../../../shared'
 import { CONSTRAINTS_FIELDS, NSFW_POLICY_TYPES } from '../../initializers/constants'
 import { exists, isArray, isBooleanValid, isFileValid } from './misc'
 import { values } from 'lodash'
+import { CONFIG } from '../../initializers/config'
 
 const USERS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.USERS
 
@@ -10,6 +11,14 @@ function isUserPasswordValid (value: string) {
   return validator.isLength(value, USERS_CONSTRAINTS_FIELDS.PASSWORD)
 }
 
+function isUserPasswordValidOrEmpty (value: string) {
+  // Empty password is only possible if emailing is enabled.
+  if (value === '') {
+    return !!CONFIG.SMTP.HOSTNAME && !!CONFIG.SMTP.PORT
+  }
+  return isUserPasswordValid(value)
+}
+
 function isUserVideoQuotaValid (value: string) {
   return exists(value) && validator.isInt(value + '', USERS_CONSTRAINTS_FIELDS.VIDEO_QUOTA)
 }
@@ -103,6 +112,7 @@ export {
   isUserVideosHistoryEnabledValid,
   isUserBlockedValid,
   isUserPasswordValid,
+  isUserPasswordValidOrEmpty,
   isUserVideoLanguages,
   isUserBlockedReasonValid,
   isUserRoleValid,
index 311d371a71f7021ce6e5a3a8c5bd406d2e41ee51..3da06402c760801c2198fb525454147011752c55 100644 (file)
@@ -502,6 +502,7 @@ let PRIVATE_RSA_KEY_SIZE = 2048
 const BCRYPT_SALT_SIZE = 10
 
 const USER_PASSWORD_RESET_LIFETIME = 60000 * 60 // 60 minutes
+const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days
 
 const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
 
@@ -764,6 +765,7 @@ export {
   LRU_CACHE,
   JOB_REQUEST_TIMEOUT,
   USER_PASSWORD_RESET_LIFETIME,
+  USER_PASSWORD_CREATE_LIFETIME,
   MEMOIZE_TTL,
   USER_EMAIL_VERIFY_LIFETIME,
   OVERVIEWS,
index 9ce6186b155ebea5049666851d70b431794ecb33..0f74d2a8c2bbf242ff3baf72ba9c64f8f4a2ed01 100644 (file)
@@ -384,6 +384,22 @@ class Emailer {
     return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
   }
 
+  addPasswordCreateEmailJob (username: string, to: string, resetPasswordUrl: string) {
+    const text = 'Hi,\n\n' +
+      `Welcome to your ${WEBSERVER.HOST} PeerTube instance. Your username is: ${username}.\n\n` +
+      `Please set your password by following this link: ${resetPasswordUrl} (this link will expire within seven days).\n\n` +
+      'Cheers,\n' +
+      `${CONFIG.EMAIL.BODY.SIGNATURE}`
+
+    const emailPayload: EmailPayload = {
+      to: [ to ],
+      subject: CONFIG.EMAIL.SUBJECT.PREFIX + 'New PeerTube account password',
+      text
+    }
+
+    return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
+  }
+
   addVerifyEmailJob (to: string, verifyEmailUrl: string) {
     const text = 'Welcome to PeerTube,\n\n' +
       `To start using PeerTube on ${WEBSERVER.HOST} you must  verify your email! ` +
index 0c5dbdd3ec13949220a0750dd1d94c2c5031443e..b4cd6f8e7e0afe75189f775d7433519ee931a242 100644 (file)
@@ -6,6 +6,7 @@ import {
   CONTACT_FORM_LIFETIME,
   USER_EMAIL_VERIFY_LIFETIME,
   USER_PASSWORD_RESET_LIFETIME,
+  USER_PASSWORD_CREATE_LIFETIME,
   VIDEO_VIEW_LIFETIME,
   WEBSERVER
 } from '../initializers/constants'
@@ -74,6 +75,14 @@ class Redis {
     return generatedString
   }
 
+  async setCreatePasswordVerificationString (userId: number) {
+    const generatedString = await generateRandomString(32)
+
+    await this.setValue(this.generateResetPasswordKey(userId), generatedString, USER_PASSWORD_CREATE_LIFETIME)
+
+    return generatedString
+  }
+
   async getResetPasswordLink (userId: number) {
     return this.getValue(this.generateResetPasswordKey(userId))
   }
index 5d52b5804a7f6b5b7c41abb3e6ebe27f4f5b8a9f..adc67a0463ecda2dfac02c10ae0adef06492a659 100644 (file)
@@ -14,6 +14,7 @@ import {
   isUserDisplayNameValid,
   isUserNSFWPolicyValid,
   isUserPasswordValid,
+  isUserPasswordValidOrEmpty,
   isUserRoleValid,
   isUserUsernameValid,
   isUserVideoLanguages,
@@ -39,7 +40,7 @@ import { Hooks } from '@server/lib/plugins/hooks'
 
 const usersAddValidator = [
   body('username').custom(isUserUsernameValid).withMessage('Should have a valid username (lowercase alphanumeric characters)'),
-  body('password').custom(isUserPasswordValid).withMessage('Should have a valid password'),
+  body('password').custom(isUserPasswordValidOrEmpty).withMessage('Should have a valid password'),
   body('email').isEmail().withMessage('Should have a valid email'),
   body('videoQuota').custom(isUserVideoQuotaValid).withMessage('Should have a valid user quota'),
   body('videoQuotaDaily').custom(isUserVideoQuotaDailyValid).withMessage('Should have a valid daily user quota'),
index f448bb2a6253aa61cc30fe3b96d5f78ba6f74634..4d597f0a3d53a2502af95c98a96256fc427309cc 100644 (file)
@@ -16,12 +16,14 @@ import {
   getMyUserVideoRating,
   getUsersList,
   immutableAssign,
+  killallServers,
   makeGetRequest,
   makePostBodyRequest,
   makePutBodyRequest,
   makeUploadRequest,
   registerUser,
   removeUser,
+  reRunServer,
   ServerInfo,
   setAccessTokensToServers,
   unblockUser,
@@ -39,6 +41,7 @@ import { VideoPrivacy } from '../../../../shared/models/videos'
 import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
 import { expect } from 'chai'
 import { UserAdminFlag } from '../../../../shared/models/users/user-flag.model'
+import { MockSmtpServer } from '../../../../shared/extra-utils/miscs/email'
 
 describe('Test users API validators', function () {
   const path = '/api/v1/users/'
@@ -50,6 +53,8 @@ describe('Test users API validators', function () {
   let serverWithRegistrationDisabled: ServerInfo
   let userAccessToken = ''
   let moderatorAccessToken = ''
+  let emailPort: number
+  let overrideConfig: Object
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   let channelId: number
 
@@ -58,9 +63,14 @@ describe('Test users API validators', function () {
   before(async function () {
     this.timeout(30000)
 
+    const emails: object[] = []
+    emailPort = await MockSmtpServer.Instance.collectEmails(emails)
+
+    overrideConfig = { signup: { limit: 8 } }
+
     {
       const res = await Promise.all([
-        flushAndRunServer(1, { signup: { limit: 7 } }),
+        flushAndRunServer(1, overrideConfig),
         flushAndRunServer(2)
       ])
 
@@ -229,6 +239,40 @@ describe('Test users API validators', function () {
       await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
     })
 
+    it('Should fail with empty password and no smtp configured', async function () {
+      const fields = immutableAssign(baseCorrectParams, { password: '' })
+
+      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
+    })
+
+    it('Should succeed with no password on a server with smtp enabled', async function () {
+      this.timeout(10000)
+
+      killallServers([ server ])
+
+      const config = immutableAssign(overrideConfig, {
+        smtp: {
+          hostname: 'localhost',
+          port: emailPort
+        }
+      })
+      await reRunServer(server, config)
+
+      const fields = immutableAssign(baseCorrectParams, {
+        password: '',
+        username: 'create_password',
+        email: 'create_password@example.com'
+      })
+
+      await makePostBodyRequest({
+        url: server.url,
+        path: path,
+        token: server.accessToken,
+        fields,
+        statusCodeExpected: 200
+      })
+    })
+
     it('Should fail with invalid admin flags', async function () {
       const fields = immutableAssign(baseCorrectParams, { adminFlags: 'toto' })
 
@@ -1102,6 +1146,8 @@ describe('Test users API validators', function () {
   })
 
   after(async function () {
+    MockSmtpServer.Instance.kill()
+
     await cleanupTests([ server, serverWithRegistrationDisabled ])
   })
 })
index f18859e5d186d2538af0bead17930066e42a50f1..95b64a45959fd3a95333e15af4b0618619524318 100644 (file)
@@ -28,10 +28,12 @@ const expect = chai.expect
 describe('Test emails', function () {
   let server: ServerInfo
   let userId: number
+  let userId2: number
   let userAccessToken: string
   let videoUUID: string
   let videoUserUUID: string
   let verificationString: string
+  let verificationString2: string
   const emails: object[] = []
   const user = {
     username: 'user_1',
@@ -122,6 +124,56 @@ describe('Test emails', function () {
     })
   })
 
+  describe('When creating a user without password', function () {
+    it('Should send a create password email', async function () {
+      this.timeout(10000)
+
+      await createUser({
+        url: server.url,
+        accessToken: server.accessToken,
+        username: 'create_password',
+        password: ''
+      })
+
+      await waitJobs(server)
+      expect(emails).to.have.lengthOf(2)
+
+      const email = emails[1]
+
+      expect(email['from'][0]['name']).equal('localhost:' + server.port)
+      expect(email['from'][0]['address']).equal('test-admin@localhost')
+      expect(email['to'][0]['address']).equal('create_password@example.com')
+      expect(email['subject']).contains('account')
+      expect(email['subject']).contains('password')
+
+      const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text'])
+      expect(verificationStringMatches).not.to.be.null
+
+      verificationString2 = verificationStringMatches[1]
+      expect(verificationString2).to.have.length.above(2)
+
+      const userIdMatches = /userId=([0-9]+)/.exec(email['text'])
+      expect(userIdMatches).not.to.be.null
+
+      userId2 = parseInt(userIdMatches[1], 10)
+    })
+
+    it('Should not reset the password with an invalid verification string', async function () {
+      await resetPassword(server.url, userId2, verificationString2 + 'c', 'newly_created_password', 403)
+    })
+
+    it('Should reset the password', async function () {
+      await resetPassword(server.url, userId2, verificationString2, 'newly_created_password')
+    })
+
+    it('Should login with this new password', async function () {
+      await userLogin(server, {
+        username: 'create_password',
+        password: 'newly_created_password'
+      })
+    })
+  })
+
   describe('When creating a video abuse', function () {
     it('Should send the notification email', async function () {
       this.timeout(10000)
@@ -130,9 +182,9 @@ describe('Test emails', function () {
       await reportVideoAbuse(server.url, server.accessToken, videoUUID, reason)
 
       await waitJobs(server)
-      expect(emails).to.have.lengthOf(2)
+      expect(emails).to.have.lengthOf(3)
 
-      const email = emails[1]
+      const email = emails[2]
 
       expect(email['from'][0]['name']).equal('localhost:' + server.port)
       expect(email['from'][0]['address']).equal('test-admin@localhost')
@@ -151,9 +203,9 @@ describe('Test emails', function () {
       await blockUser(server.url, userId, server.accessToken, 204, reason)
 
       await waitJobs(server)
-      expect(emails).to.have.lengthOf(3)
+      expect(emails).to.have.lengthOf(4)
 
-      const email = emails[2]
+      const email = emails[3]
 
       expect(email['from'][0]['name']).equal('localhost:' + server.port)
       expect(email['from'][0]['address']).equal('test-admin@localhost')
@@ -169,9 +221,9 @@ describe('Test emails', function () {
       await unblockUser(server.url, userId, server.accessToken, 204)
 
       await waitJobs(server)
-      expect(emails).to.have.lengthOf(4)
+      expect(emails).to.have.lengthOf(5)
 
-      const email = emails[3]
+      const email = emails[4]
 
       expect(email['from'][0]['name']).equal('localhost:' + server.port)
       expect(email['from'][0]['address']).equal('test-admin@localhost')
@@ -189,9 +241,9 @@ describe('Test emails', function () {
       await addVideoToBlacklist(server.url, server.accessToken, videoUserUUID, reason)
 
       await waitJobs(server)
-      expect(emails).to.have.lengthOf(5)
+      expect(emails).to.have.lengthOf(6)
 
-      const email = emails[4]
+      const email = emails[5]
 
       expect(email['from'][0]['name']).equal('localhost:' + server.port)
       expect(email['from'][0]['address']).equal('test-admin@localhost')
@@ -207,9 +259,9 @@ describe('Test emails', function () {
       await removeVideoFromBlacklist(server.url, server.accessToken, videoUserUUID)
 
       await waitJobs(server)
-      expect(emails).to.have.lengthOf(6)
+      expect(emails).to.have.lengthOf(7)
 
-      const email = emails[5]
+      const email = emails[6]
 
       expect(email['from'][0]['name']).equal('localhost:' + server.port)
       expect(email['from'][0]['address']).equal('test-admin@localhost')
@@ -227,9 +279,9 @@ describe('Test emails', function () {
       await askSendVerifyEmail(server.url, 'user_1@example.com')
 
       await waitJobs(server)
-      expect(emails).to.have.lengthOf(7)
+      expect(emails).to.have.lengthOf(8)
 
-      const email = emails[6]
+      const email = emails[7]
 
       expect(email['from'][0]['name']).equal('localhost:' + server.port)
       expect(email['from'][0]['address']).equal('test-admin@localhost')
index 180f65bcf25c90cf2ae58e5a39eeaa4f63b877f6..40f7e0cdd70f137252b9affbd9af4b3dd12b164d 100644 (file)
@@ -2781,7 +2781,7 @@ components:
           description: 'The user username '
         password:
           type: string
-          description: 'The user password '
+          description: 'The user password. If the smtp server is configured, you can leave empty and an email will be sent '
         email:
           type: string
           description: 'The user email '