1
0
Fork 0

add user account email verificiation (#977)

* add user account email verificiation

includes server and client code to:

* enable verificationRequired via custom config
* send verification email with registration
* ask for verification email
* verify via email
* prevent login if not verified and required
* conditional client links to ask for new verification email

* allow login for verified=null

these are users created when verification not required
should still be able to login when verification is enabled

* refactor email verifcation pr

* change naming from verified to emailVerified
* change naming from askVerifyEmail to askSendVerifyEmail
* undo unrelated automatic prettier formatting on api/config
* use redirectService for home
* remove redundant success notification on email verified

* revert test.yaml smpt host
This commit is contained in:
Josh Morel 2018-08-31 03:18:19 -04:00 committed by Chocobozzz
parent 04291e1ba4
commit d9eaee3939
42 changed files with 715 additions and 24 deletions

View File

@ -91,6 +91,11 @@
i18n-labelText labelText="Signup enabled"
></my-peertube-checkbox>
<my-peertube-checkbox *ngIf="isSignupEnabled()"
inputName="signupRequiresEmailVerification" formControlName="signupRequiresEmailVerification"
i18n-labelText labelText="Signup requires email verification"
></my-peertube-checkbox>
<div *ngIf="isSignupEnabled()" class="form-group">
<label i18n for="signupLimit">Signup limit</label>
<input

View File

@ -90,6 +90,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
cacheCaptionsSize: this.customConfigValidatorsService.CACHE_CAPTIONS_SIZE,
signupEnabled: null,
signupLimit: this.customConfigValidatorsService.SIGNUP_LIMIT,
signupRequiresEmailVerification: null,
importVideosHttpEnabled: null,
importVideosTorrentEnabled: null,
adminEmail: this.customConfigValidatorsService.ADMIN_EMAIL,
@ -187,7 +188,8 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
},
signup: {
enabled: this.form.value['signupEnabled'],
limit: this.form.value['signupLimit']
limit: this.form.value['signupLimit'],
requiresEmailVerification: this.form.value['signupRequiresEmailVerification']
},
admin: {
email: this.form.value['adminEmail']
@ -250,6 +252,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
cacheCaptionsSize: this.customConfig.cache.captions.size,
signupEnabled: this.customConfig.signup.enabled,
signupLimit: this.customConfig.signup.limit,
signupRequiresEmailVerification: this.customConfig.signup.requiresEmailVerification,
adminEmail: this.customConfig.admin.email,
userVideoQuota: this.customConfig.user.videoQuota,
userVideoQuotaDaily: this.customConfig.user.videoQuotaDaily,

View File

@ -0,0 +1,2 @@
export * from '@app/+verify-account/verify-account-routing.module'
export * from '@app/+verify-account/verify-account.module'

View File

@ -0,0 +1,22 @@
<div class="margin-content">
<div i18n class="title-page title-page-single">
Request email for account verification
</div>
<form *ngIf="requiresEmailVerification; else emailVerificationNotRequired" role="form" (ngSubmit)="askSendVerifyEmail()" [formGroup]="form">
<div class="form-group">
<label i18n for="verify-email-email">Email</label>
<input
type="email" id="verify-email-email" i18n-placeholder placeholder="Email address" required
formControlName="verify-email-email" [ngClass]="{ 'input-error': formErrors['verify-email-email'] }"
>
<div *ngIf="formErrors['verify-email-email']" class="form-error">
{{ formErrors['verify-email-email'] }}
</div>
</div>
<input type="submit" i18n-value value="Send verification email" [disabled]="!form.valid">
</form>
<ng-template #emailVerificationNotRequired>
<div i18n>This instance does not require email verification.</div>
</ng-template>
</div>

View File

@ -0,0 +1,12 @@
@import '_variables';
@import '_mixins';
input:not([type=submit]) {
@include peertube-input-text(340px);
display: block;
}
input[type=submit] {
@include peertube-button;
@include orange-button;
}

View File

@ -0,0 +1,58 @@
import { Component, OnInit } from '@angular/core'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { NotificationsService } from 'angular2-notifications'
import { ServerService } from '@app/core/server'
import { RedirectService } from '@app/core'
import { UserService, FormReactive } from '@app/shared'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
@Component({
selector: 'my-verify-account-ask-send-email',
templateUrl: './verify-account-ask-send-email.component.html',
styleUrls: [ './verify-account-ask-send-email.component.scss' ]
})
export class VerifyAccountAskSendEmailComponent extends FormReactive implements OnInit {
constructor (
protected formValidatorService: FormValidatorService,
private userValidatorsService: UserValidatorsService,
private userService: UserService,
private serverService: ServerService,
private notificationsService: NotificationsService,
private redirectService: RedirectService,
private i18n: I18n
) {
super()
}
get requiresEmailVerification () {
return this.serverService.getConfig().signup.requiresEmailVerification
}
ngOnInit () {
this.buildForm({
'verify-email-email': this.userValidatorsService.USER_EMAIL
})
}
askSendVerifyEmail () {
const email = this.form.value['verify-email-email']
this.userService.askSendVerifyEmail(email)
.subscribe(
() => {
const message = this.i18n(
'An email with verification link will be sent to {{email}}.',
{ email }
)
this.notificationsService.success(this.i18n('Success'), message)
this.redirectService.redirectToHomepage()
},
err => {
this.notificationsService.error(this.i18n('Error'), err.message)
}
)
}
}

View File

@ -0,0 +1,15 @@
<div class="margin-content">
<div i18n class="title-page title-page-single">
Verify account email confirmation
</div>
<div i18n *ngIf="success; else verificationError">
Your email has been verified and you may now login. Redirecting...
</div>
<ng-template #verificationError>
<div>
<span i18n>An error occurred. </span>
<a i18n routerLink="/verify-account/ask-email">Request new verification email.</a>
</div>
</ng-template>
</div>

View File

@ -0,0 +1,54 @@
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { NotificationsService } from 'angular2-notifications'
import { UserService } from '@app/shared'
@Component({
selector: 'my-verify-account-email',
templateUrl: './verify-account-email.component.html'
})
export class VerifyAccountEmailComponent implements OnInit {
success = false
private userId: number
private verificationString: string
constructor (
private userService: UserService,
private notificationsService: NotificationsService,
private router: Router,
private route: ActivatedRoute,
private i18n: I18n
) {
}
ngOnInit () {
this.userId = this.route.snapshot.queryParams['userId']
this.verificationString = this.route.snapshot.queryParams['verificationString']
if (!this.userId || !this.verificationString) {
this.notificationsService.error(this.i18n('Error'), this.i18n('Unable to find user id or verification string.'))
} else {
this.verifyEmail()
}
}
verifyEmail () {
this.userService.verifyEmail(this.userId, this.verificationString)
.subscribe(
() => {
this.success = true
setTimeout(() => {
this.router.navigate([ '/login' ])
}, 2000)
},
err => {
this.notificationsService.error(this.i18n('Error'), err.message)
}
)
}
}

View File

@ -0,0 +1,42 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { MetaGuard } from '@ngx-meta/core'
import { VerifyAccountEmailComponent } from '@app/+verify-account/verify-account-email/verify-account-email.component'
import {
VerifyAccountAskSendEmailComponent
} from '@app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component'
const verifyAccountRoutes: Routes = [
{
path: '',
canActivateChild: [ MetaGuard ],
children: [
{
path: 'email',
component: VerifyAccountEmailComponent,
data: {
meta: {
title: 'Verify account email'
}
}
},
{
path: 'ask-send-email',
component: VerifyAccountAskSendEmailComponent,
data: {
meta: {
title: 'Verify account ask send email'
}
}
}
]
}
]
@NgModule({
imports: [ RouterModule.forChild(verifyAccountRoutes) ],
exports: [ RouterModule ]
})
export class VerifyAccountRoutingModule {}

View File

@ -0,0 +1,27 @@
import { NgModule } from '@angular/core'
import { VerifyAccountRoutingModule } from '@app/+verify-account/verify-account-routing.module'
import { VerifyAccountEmailComponent } from '@app/+verify-account/verify-account-email/verify-account-email.component'
import {
VerifyAccountAskSendEmailComponent
} from '@app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component'
import { SharedModule } from '@app/shared'
@NgModule({
imports: [
VerifyAccountRoutingModule,
SharedModule
],
declarations: [
VerifyAccountEmailComponent,
VerifyAccountAskSendEmailComponent
],
exports: [
],
providers: [
]
})
export class VerifyAccountModule { }

View File

@ -13,6 +13,10 @@ const routes: Routes = [
path: 'my-account',
loadChildren: './+my-account/my-account.module#MyAccountModule'
},
{
path: 'verify-account',
loadChildren: './+verify-account/verify-account.module#VerifyAccountModule'
},
{
path: 'accounts',
loadChildren: './+accounts/accounts.module#AccountsModule'

View File

@ -40,7 +40,8 @@ export class ServerService {
serverVersion: 'Unknown',
signup: {
allowed: false,
allowedForCurrentIP: false
allowedForCurrentIP: false,
requiresEmailVerification: false
},
transcoding: {
enabledResolutions: []

View File

@ -3,7 +3,9 @@
Login
</div>
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
<div *ngIf="error" class="alert alert-danger">{{ error }}
<span *ngIf="error === 'User email is not verified.'"> <a i18n routerLink="/verify-account/ask-send-email">Request new verification email.</a></span>
</div>
<form role="form" (ngSubmit)="login()" [formGroup]="form">
<div class="form-group">

View File

@ -94,4 +94,27 @@ export class UserService {
catchError(res => this.restExtractor.handleError(res))
)
}
verifyEmail (userId: number, verificationString: string) {
const url = `${UserService.BASE_USERS_URL}/${userId}/verify-email`
const body = {
verificationString
}
return this.authHttp.post(url, body)
.pipe(
map(this.restExtractor.extractDataBool),
catchError(res => this.restExtractor.handleError(res))
)
}
askSendVerifyEmail (email: string) {
const url = UserService.BASE_USERS_URL + '/ask-send-verify-email'
return this.authHttp.post(url, { email })
.pipe(
map(this.restExtractor.extractDataBool),
catchError(err => this.restExtractor.handleError(err))
)
}
}

View File

@ -3,7 +3,7 @@ import { Router } from '@angular/router'
import { NotificationsService } from 'angular2-notifications'
import { UserCreate } from '../../../../shared'
import { FormReactive, UserService, UserValidatorsService } from '../shared'
import { RedirectService } from '@app/core'
import { RedirectService, ServerService } from '@app/core'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
@ -21,6 +21,7 @@ export class SignupComponent extends FormReactive implements OnInit {
private router: Router,
private notificationsService: NotificationsService,
private userService: UserService,
private serverService: ServerService,
private redirectService: RedirectService,
private i18n: I18n
) {
@ -31,6 +32,10 @@ export class SignupComponent extends FormReactive implements OnInit {
return window.location.host
}
get requiresEmailVerification () {
return this.serverService.getConfig().signup.requiresEmailVerification
}
ngOnInit () {
this.buildForm({
username: this.userValidatorsService.USER_USERNAME,
@ -47,10 +52,17 @@ export class SignupComponent extends FormReactive implements OnInit {
this.userService.signup(userCreate).subscribe(
() => {
this.notificationsService.success(
this.i18n('Success'),
this.i18n('Registration for {{username}} complete.', { username: userCreate.username })
)
if (this.requiresEmailVerification) {
this.notificationsService.alert(
this.i18n('Welcome'),
this.i18n('Please check your email to verify your account and complete signup.')
)
} else {
this.notificationsService.success(
this.i18n('Success'),
this.i18n('Registration for {{username}} complete.', { username: userCreate.username })
)
}
this.redirectService.redirectToHomepage()
},

View File

@ -74,6 +74,7 @@ admin:
signup:
enabled: false
limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited
requires_email_verification: false
filters:
cidr: # You can specify CIDR ranges to whitelist (empty = no filtering) or blacklist
whitelist: []

View File

@ -87,6 +87,7 @@ admin:
signup:
enabled: false
limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited
requires_email_verification: false
filters:
cidr: # You can specify CIDR ranges to whitelist (empty = no filtering) or blacklist
whitelist: []

View File

@ -29,6 +29,7 @@ cache:
signup:
enabled: true
requires_email_verification: false
transcoding:
enabled: true

View File

@ -60,7 +60,8 @@ async function getConfig (req: express.Request, res: express.Response, next: exp
serverVersion: packageJSON.version,
signup: {
allowed,
allowedForCurrentIP
allowedForCurrentIP,
requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
},
transcoding: {
enabledResolutions
@ -159,12 +160,20 @@ async function updateCustomConfig (req: express.Request, res: express.Response,
toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10)
// camelCase to snake_case key
const toUpdateJSON = omit(toUpdate, 'user.videoQuota', 'instance.defaultClientRoute', 'instance.shortDescription', 'cache.videoCaptions')
const toUpdateJSON = omit(
toUpdate,
'user.videoQuota',
'instance.defaultClientRoute',
'instance.shortDescription',
'cache.videoCaptions',
'signup.requiresEmailVerification'
)
toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota
toUpdateJSON.user['video_quota_daily'] = toUpdate.user.videoQuotaDaily
toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute
toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription
toUpdateJSON.instance['default_nsfw_policy'] = toUpdate.instance.defaultNSFWPolicy
toUpdateJSON.signup['requires_email_verification'] = toUpdate.signup.requiresEmailVerification
await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 })
@ -220,7 +229,8 @@ function customConfig (): CustomConfig {
},
signup: {
enabled: CONFIG.SIGNUP.ENABLED,
limit: CONFIG.SIGNUP.LIMIT
limit: CONFIG.SIGNUP.LIMIT,
requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
},
admin: {
email: CONFIG.ADMIN.EMAIL

View File

@ -25,7 +25,10 @@ import {
usersSortValidator,
usersUpdateValidator
} from '../../../middlewares'
import { usersAskResetPasswordValidator, usersBlockingValidator, usersResetPasswordValidator } from '../../../middlewares/validators'
import {
usersAskResetPasswordValidator, usersBlockingValidator, usersResetPasswordValidator,
usersAskSendVerifyEmailValidator, usersVerifyEmailValidator
} from '../../../middlewares/validators'
import { UserModel } from '../../../models/account/user'
import { OAuthTokenModel } from '../../../models/oauth/oauth-token'
import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger'
@ -110,6 +113,17 @@ usersRouter.post('/:id/reset-password',
asyncMiddleware(resetUserPassword)
)
usersRouter.post('/ask-send-verify-email',
loginRateLimiter,
asyncMiddleware(usersAskSendVerifyEmailValidator),
asyncMiddleware(askSendVerifyUserEmail)
)
usersRouter.post('/:id/verify-email',
asyncMiddleware(usersVerifyEmailValidator),
asyncMiddleware(verifyUserEmail)
)
usersRouter.post('/token',
loginRateLimiter,
token,
@ -165,7 +179,8 @@ async function registerUser (req: express.Request, res: express.Response) {
autoPlayVideo: true,
role: UserRole.USER,
videoQuota: CONFIG.USER.VIDEO_QUOTA,
videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY,
emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
})
const { user } = await createUserAccountAndChannel(userToCreate)
@ -173,6 +188,10 @@ async function registerUser (req: express.Request, res: express.Response) {
auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON()))
logger.info('User %s with its channel and account registered.', body.username)
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
await sendVerifyUserEmail(user)
}
return res.type('json').status(204).end()
}
@ -261,6 +280,30 @@ async function resetUserPassword (req: express.Request, res: express.Response, n
return res.status(204).end()
}
async function sendVerifyUserEmail (user: UserModel) {
const verificationString = await Redis.Instance.setVerifyEmailVerificationString(user.id)
const url = CONFIG.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, next: express.NextFunction) {
const user = res.locals.user as UserModel
await sendVerifyUserEmail(user)
return res.status(204).end()
}
async function verifyUserEmail (req: express.Request, res: express.Response, next: express.NextFunction) {
const user = res.locals.user as UserModel
user.emailVerified = true
await user.save()
return res.status(204).end()
}
function success (req: express.Request, res: express.Response, next: express.NextFunction) {
res.end()
}

View File

@ -234,6 +234,7 @@ const customConfigKeysToKeep = [
'cache-captions-size',
'signup-enabled',
'signup-limit',
'signup-requiresEmailVerification',
'admin-email',
'user-videoQuota',
'transcoding-enabled',

View File

@ -33,6 +33,10 @@ function isUserDescriptionValid (value: string) {
return value === null || (exists(value) && validator.isLength(value, CONSTRAINTS_FIELDS.USERS.DESCRIPTION))
}
function isUserEmailVerifiedValid (value: any) {
return isBooleanValid(value)
}
const nsfwPolicies = values(NSFW_POLICY_TYPES)
function isUserNSFWPolicyValid (value: any) {
return exists(value) && nsfwPolicies.indexOf(value) !== -1
@ -72,6 +76,7 @@ export {
isUserVideoQuotaValid,
isUserVideoQuotaDailyValid,
isUserUsernameValid,
isUserEmailVerifiedValid,
isUserNSFWPolicyValid,
isUserAutoPlayVideoValid,
isUserDisplayNameValid,

View File

@ -49,7 +49,8 @@ function checkMissedConfig () {
'log.level',
'user.video_quota', 'user.video_quota_daily',
'cache.previews.size', 'admin.email',
'signup.enabled', 'signup.limit', 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
'signup.enabled', 'signup.limit', 'signup.requires_email_verification',
'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
'transcoding.enabled', 'transcoding.threads',
'import.videos.http.enabled',
'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route',

View File

@ -15,7 +15,7 @@ let config: IConfig = require('config')
// ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 260
const LAST_MIGRATION_VERSION = 265
// ---------------------------------------------------------------------------
@ -204,6 +204,7 @@ const CONFIG = {
SIGNUP: {
get ENABLED () { return config.get<boolean>('signup.enabled') },
get LIMIT () { return config.get<number>('signup.limit') },
get REQUIRES_EMAIL_VERIFICATION () { return config.get<boolean>('signup.requires_email_verification') },
FILTERS: {
CIDR: {
get WHITELIST () { return config.get<string[]>('signup.filters.cidr.whitelist') },
@ -500,6 +501,8 @@ const BCRYPT_SALT_SIZE = 10
const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes
const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
const NSFW_POLICY_TYPES: { [ id: string]: NSFWPolicyType } = {
DO_NOT_LIST: 'do_not_list',
BLUR: 'blur',
@ -661,6 +664,7 @@ export {
VIDEO_ABUSE_STATES,
JOB_REQUEST_TIMEOUT,
USER_PASSWORD_RESET_LIFETIME,
USER_EMAIL_VERIFY_LIFETIME,
IMAGE_MIMETYPE_EXT,
SCHEDULER_INTERVALS_MS,
REPEAT_JOBS,

View File

@ -122,6 +122,7 @@ async function createOAuthAdminIfNotExist () {
email,
password,
role,
verified: true,
nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
videoQuota: -1,
videoQuotaDaily: -1

View File

@ -0,0 +1,24 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<any> {
{
const data = {
type: Sequelize.BOOLEAN,
allowNull: true,
defaultValue: null
}
await utils.queryInterface.addColumn('user', 'emailVerified', data)
}
}
function down (options) {
throw new Error('Not implemented.')
}
export { up, down }

View File

@ -89,6 +89,23 @@ class Emailer {
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 ${CONFIG.WEBSERVER.HOST} you must verify your email! ` +
`Please follow this link to verify this email belongs to you: ${verifyEmailUrl}\n\n` +
`If you are not the person who initiated this request, please ignore this email.\n\n` +
`Cheers,\n` +
`PeerTube.`
const emailPayload: EmailPayload = {
to: [ to ],
subject: 'Verify your PeerTube email',
text
}
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
}
async addVideoAbuseReportJob (videoId: number) {
const video = await VideoModel.load(videoId)
if (!video) throw new Error('Unknown Video id during Abuse report.')

View File

@ -3,6 +3,7 @@ import { logger } from '../helpers/logger'
import { UserModel } from '../models/account/user'
import { OAuthClientModel } from '../models/oauth/oauth-client'
import { OAuthTokenModel } from '../models/oauth/oauth-token'
import { CONFIG } from '../initializers/constants'
type TokenInfo = { accessToken: string, refreshToken: string, accessTokenExpiresAt: Date, refreshTokenExpiresAt: Date }
@ -37,6 +38,10 @@ async function getUser (usernameOrEmail: string, password: string) {
if (user.blocked) throw new AccessDeniedError('User is blocked.')
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION && user.emailVerified === false) {
throw new AccessDeniedError('User email is not verified.')
}
return user
}

View File

@ -2,7 +2,7 @@ import * as express from 'express'
import { createClient, RedisClient } from 'redis'
import { logger } from '../helpers/logger'
import { generateRandomString } from '../helpers/utils'
import { CONFIG, USER_PASSWORD_RESET_LIFETIME, VIDEO_VIEW_LIFETIME } from '../initializers'
import { CONFIG, USER_PASSWORD_RESET_LIFETIME, USER_EMAIL_VERIFY_LIFETIME, VIDEO_VIEW_LIFETIME } from '../initializers'
type CachedRoute = {
body: string,
@ -60,6 +60,18 @@ class Redis {
return this.getValue(this.generateResetPasswordKey(userId))
}
async setVerifyEmailVerificationString (userId: number) {
const generatedString = await generateRandomString(32)
await this.setValue(this.generateVerifyEmailKey(userId), generatedString, USER_EMAIL_VERIFY_LIFETIME)
return generatedString
}
async getVerifyEmailLink (userId: number) {
return this.getValue(this.generateVerifyEmailKey(userId))
}
setIPVideoView (ip: string, videoUUID: string) {
return this.setValue(this.buildViewKey(ip, videoUUID), '1', VIDEO_VIEW_LIFETIME)
}
@ -135,6 +147,10 @@ class Redis {
return 'reset-password-' + userId
}
generateVerifyEmailKey (userId: number) {
return 'verify-email-' + userId
}
buildViewKey (ip: string, videoUUID: string) {
return videoUUID + '-' + ip
}

View File

@ -248,6 +248,48 @@ const usersResetPasswordValidator = [
}
]
const usersAskSendVerifyEmailValidator = [
body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking askUsersSendVerifyEmail parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
const exists = await checkUserEmailExist(req.body.email, res, false)
if (!exists) {
logger.debug('User with email %s does not exist (asking verify email).', req.body.email)
// Do not leak our emails
return res.status(204).end()
}
return next()
}
]
const usersVerifyEmailValidator = [
param('id').isInt().not().isEmpty().withMessage('Should have a valid id'),
body('verificationString').not().isEmpty().withMessage('Should have a valid verification string'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking usersVerifyEmail parameters', { parameters: req.params })
if (areValidationErrors(req, res)) return
if (!await checkUserIdExist(req.params.id, res)) return
const user = res.locals.user as UserModel
const redisVerificationString = await Redis.Instance.getVerifyEmailLink(user.id)
if (redisVerificationString !== req.body.verificationString) {
return res
.status(403)
.send({ error: 'Invalid verification string.' })
.end()
}
return next()
}
]
// ---------------------------------------------------------------------------
export {
@ -263,7 +305,9 @@ export {
ensureUserRegistrationAllowedForIP,
usersGetValidator,
usersAskResetPasswordValidator,
usersResetPasswordValidator
usersResetPasswordValidator,
usersAskSendVerifyEmailValidator,
usersVerifyEmailValidator
}
// ---------------------------------------------------------------------------

View File

@ -24,6 +24,7 @@ import {
isUserBlockedReasonValid,
isUserBlockedValid,
isUserNSFWPolicyValid,
isUserEmailVerifiedValid,
isUserPasswordValid,
isUserRoleValid,
isUserUsernameValid,
@ -92,6 +93,12 @@ export class UserModel extends Model<UserModel> {
@Column(DataType.STRING(400))
email: string
@AllowNull(true)
@Default(null)
@Is('UserEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean'))
@Column
emailVerified: boolean
@AllowNull(false)
@Is('UserNSFWPolicy', value => throwIfNotValid(value, isUserNSFWPolicyValid, 'NSFW policy'))
@Column(DataType.ENUM(values(NSFW_POLICY_TYPES)))
@ -304,6 +311,7 @@ export class UserModel extends Model<UserModel> {
id: this.id,
username: this.username,
email: this.email,
emailVerified: this.emailVerified,
nsfwPolicy: this.nsfwPolicy,
autoPlayVideo: this.autoPlayVideo,
role: this.role,

View File

@ -42,7 +42,8 @@ describe('Test config API validators', function () {
},
signup: {
enabled: false,
limit: 5
limit: 5,
requiresEmailVerification: false
},
admin: {
email: 'superadmin1@example.com'

View File

@ -737,6 +737,28 @@ describe('Test users API validators', function () {
})
})
describe('When asking for an account verification email', function () {
const path = '/api/v1/users/ask-send-verify-email'
it('Should fail with a missing email', async function () {
const fields = {}
await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
})
it('Should fail with an invalid email', async function () {
const fields = { email: 'hello' }
await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
})
it('Should succeed with the correct params', async function () {
const fields = { email: 'admin@example.com' }
await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 204 })
})
})
after(async function () {
killallServers([ server, serverWithRegistrationDisabled ])

View File

@ -35,6 +35,7 @@ function checkInitialConfig (data: CustomConfig) {
expect(data.cache.captions.size).to.equal(1)
expect(data.signup.enabled).to.be.true
expect(data.signup.limit).to.equal(4)
expect(data.signup.requiresEmailVerification).to.be.false
expect(data.admin.email).to.equal('admin1@example.com')
expect(data.user.videoQuota).to.equal(5242880)
expect(data.user.videoQuotaDaily).to.equal(-1)
@ -64,6 +65,7 @@ function checkUpdatedConfig (data: CustomConfig) {
expect(data.cache.captions.size).to.equal(3)
expect(data.signup.enabled).to.be.false
expect(data.signup.limit).to.equal(5)
expect(data.signup.requiresEmailVerification).to.be.true
expect(data.admin.email).to.equal('superadmin1@example.com')
expect(data.user.videoQuota).to.equal(5242881)
expect(data.user.videoQuotaDaily).to.equal(318742)
@ -148,7 +150,8 @@ describe('Test config', function () {
},
signup: {
enabled: false,
limit: 5
limit: 5,
requiresEmailVerification: true
},
admin: {
email: 'superadmin1@example.com'

View File

@ -5,6 +5,7 @@ import 'mocha'
import {
addVideoToBlacklist,
askResetPassword,
askSendVerifyEmail,
blockUser,
createUser, removeVideoFromBlacklist,
reportVideoAbuse,
@ -12,7 +13,8 @@ import {
runServer,
unblockUser,
uploadVideo,
userLogin
userLogin,
verifyEmail
} from '../../utils'
import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index'
import { mockSmtpServer } from '../../utils/miscs/email'
@ -207,6 +209,44 @@ describe('Test emails', function () {
})
})
describe('When verifying a user email', function () {
it('Should ask to send the verification email', async function () {
this.timeout(10000)
await askSendVerifyEmail(server.url, 'user_1@example.com')
await waitJobs(server)
expect(emails).to.have.lengthOf(7)
const email = emails[6]
expect(email['from'][0]['address']).equal('test-admin@localhost')
expect(email['to'][0]['address']).equal('user_1@example.com')
expect(email['subject']).contains('Verify')
const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text'])
expect(verificationStringMatches).not.to.be.null
verificationString = verificationStringMatches[1]
expect(verificationString).to.not.be.undefined
expect(verificationString).to.have.length.above(2)
const userIdMatches = /userId=([0-9]+)/.exec(email['text'])
expect(userIdMatches).not.to.be.null
userId = parseInt(userIdMatches[1], 10)
})
it('Should not verify the email with an invalid verification string', async function () {
await verifyEmail(server.url, userId, verificationString + 'b', 403)
})
it('Should verify the email', async function () {
await verifyEmail(server.url, userId, verificationString)
})
})
after(async function () {
killallServers([ server ])
})

View File

@ -1,3 +1,4 @@
import './user-subscriptions'
import './users'
import './users-verification'
import './users-multiple-servers'

View File

@ -0,0 +1,133 @@
/* tslint:disable:no-unused-expression */
import * as chai from 'chai'
import 'mocha'
import {
registerUser, flushTests, getUserInformation, getMyUserInformation, killallServers,
userLogin, login, runServer, ServerInfo, verifyEmail, updateCustomSubConfig
} from '../../utils'
import { setAccessTokensToServers } from '../../utils/users/login'
import { mockSmtpServer } from '../../utils/miscs/email'
import { waitJobs } from '../../utils/server/jobs'
const expect = chai.expect
describe('Test users account verification', function () {
let server: ServerInfo
let userId: number
let verificationString: string
let expectedEmailsLength = 0
const user1 = {
username: 'user_1',
password: 'super password'
}
const user2 = {
username: 'user_2',
password: 'super password'
}
const emails: object[] = []
before(async function () {
this.timeout(30000)
await mockSmtpServer(emails)
await flushTests()
const overrideConfig = {
smtp: {
hostname: 'localhost'
}
}
server = await runServer(1, overrideConfig)
await setAccessTokensToServers([ server ])
})
it('Should register user and send verification email if verification required', async function () {
this.timeout(5000)
await updateCustomSubConfig(server.url, server.accessToken, {
signup: {
enabled: true,
requiresEmailVerification: true,
limit: 10
}
})
await registerUser(server.url, user1.username, user1.password)
await waitJobs(server)
expectedEmailsLength++
expect(emails).to.have.lengthOf(expectedEmailsLength)
const email = emails[expectedEmailsLength - 1]
const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text'])
expect(verificationStringMatches).not.to.be.null
verificationString = verificationStringMatches[1]
expect(verificationString).to.have.length.above(2)
const userIdMatches = /userId=([0-9]+)/.exec(email['text'])
expect(userIdMatches).not.to.be.null
userId = parseInt(userIdMatches[1], 10)
const resUserInfo = await getUserInformation(server.url, server.accessToken, userId)
expect(resUserInfo.body.emailVerified).to.be.false
})
it('Should not allow login for user with unverified email', async function () {
const resLogin = await login(server.url, server.client, user1, 400)
expect(resLogin.body.error).to.contain('User email is not verified.')
})
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 resUserVerified = await getUserInformation(server.url, server.accessToken, userId)
expect(resUserVerified.body.emailVerified).to.be.true
})
it('Should register user not requiring email verification if setting not enabled', async function () {
this.timeout(5000)
await updateCustomSubConfig(server.url, server.accessToken, {
signup: {
enabled: true,
requiresEmailVerification: false,
limit: 10
}
})
await registerUser(server.url, user2.username, user2.password)
await waitJobs(server)
expect(emails).to.have.lengthOf(expectedEmailsLength)
const accessToken = await userLogin(server, user2)
const resMyUserInfo = await getMyUserInformation(server.url, accessToken)
expect(resMyUserInfo.body.emailVerified).to.be.null
})
it('Should allow login for user with unverified email when setting later enabled', async function () {
await updateCustomSubConfig(server.url, server.accessToken, {
signup: {
enabled: true,
requiresEmailVerification: true,
limit: 10
}
})
await userLogin(server, user2)
})
after(async function () {
killallServers([ server ])
// Keep the logs if the test failed
if (this[ 'ok' ]) {
await flushTests()
}
})
})

View File

@ -7,7 +7,7 @@ import {
createUser, flushTests, getBlacklistedVideosList, getMyUserInformation, getMyUserVideoQuotaUsed, getMyUserVideoRating,
getUserInformation, getUsersList, getUsersListPaginationAndSort, getVideosList, killallServers, login, makePutBodyRequest, rateVideo,
registerUser, removeUser, removeVideo, runServer, ServerInfo, testImage, updateMyAvatar, updateMyUser, updateUser, uploadVideo, userLogin,
deleteMe, blockUser, unblockUser
deleteMe, blockUser, unblockUser, updateCustomSubConfig
} from '../../utils/index'
import { follow } from '../../utils/server/follows'
import { setAccessTokensToServers } from '../../utils/users/login'

View File

@ -74,7 +74,8 @@ function updateCustomSubConfig (url: string, token: string, newConfig: any) {
},
signup: {
enabled: false,
limit: 5
limit: 5,
requiresEmailVerification: false
},
admin: {
email: 'superadmin1@example.com'

View File

@ -246,6 +246,28 @@ function resetPassword (url: string, userId: number, verificationString: string,
})
}
function askSendVerifyEmail (url: string, email: string) {
const path = '/api/v1/users/ask-send-verify-email'
return makePostBodyRequest({
url,
path,
fields: { email },
statusCodeExpected: 204
})
}
function verifyEmail (url: string, userId: number, verificationString: string, statusCodeExpected = 204) {
const path = '/api/v1/users/' + userId + '/verify-email'
return makePostBodyRequest({
url,
path,
fields: { verificationString },
statusCodeExpected
})
}
// ---------------------------------------------------------------------------
export {
@ -265,5 +287,7 @@ export {
unblockUser,
askResetPassword,
resetPassword,
updateMyAvatar
updateMyAvatar,
askSendVerifyEmail,
verifyEmail
}

View File

@ -34,6 +34,7 @@ export interface CustomConfig {
signup: {
enabled: boolean
limit: number
requiresEmailVerification: boolean
}
admin: {

View File

@ -16,7 +16,8 @@ export interface ServerConfig {
signup: {
allowed: boolean,
allowedForCurrentIP: boolean
allowedForCurrentIP: boolean,
requiresEmailVerification: boolean
}
transcoding: {