Creating a user with an empty password will send an email to let him set his password (#2479)

* 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
pull/2507/head
John Livingston 2020-02-17 10:16:52 +01:00 committed by GitHub
parent c5621bd23b
commit 45f1bd72a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 201 additions and 18 deletions

View 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')
}

View File

@ -29,6 +29,13 @@
<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'] }"

View File

@ -92,6 +92,10 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
return false
}
isPasswordOptional () {
return false
}
getFormButtonTitle () {
return this.i18n('Update user')
}

View 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
}
},
{

View 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: {

View 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({

View 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,

View 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,

View 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! ` +

View 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))
}

View 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'),

View 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 ])
})
})

View 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')

View 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 '