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
This commit is contained in:
parent
c5621bd23b
commit
45f1bd72a0
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -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'] }"
|
||||
|
|
|
@ -92,6 +92,10 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
|
|||
return false
|
||||
}
|
||||
|
||||
isPasswordOptional () {
|
||||
return false
|
||||
}
|
||||
|
||||
getFormButtonTitle () {
|
||||
return this.i18n('Update user')
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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! ` +
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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 ])
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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 '
|
||||
|
|
Loading…
Reference in New Issue