aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJosh Morel <morel.josh@hotmail.com>2018-08-31 03:18:19 -0400
committerChocobozzz <me@florianbigard.com>2018-08-31 09:18:19 +0200
commitd9eaee3939bf2e93e5d775d32bce77842201faba (patch)
treec115acb3611986b98f51b3addf29ebe66f63ee7f
parent04291e1ba44032165388758e993d385a10c1c5a1 (diff)
downloadPeerTube-d9eaee3939bf2e93e5d775d32bce77842201faba.tar.gz
PeerTube-d9eaee3939bf2e93e5d775d32bce77842201faba.tar.zst
PeerTube-d9eaee3939bf2e93e5d775d32bce77842201faba.zip
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
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html5
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts5
-rw-r--r--client/src/app/+verify-account/index.ts2
-rw-r--r--client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html22
-rw-r--r--client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.scss12
-rw-r--r--client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts58
-rw-r--r--client/src/app/+verify-account/verify-account-email/verify-account-email.component.html15
-rw-r--r--client/src/app/+verify-account/verify-account-email/verify-account-email.component.ts54
-rw-r--r--client/src/app/+verify-account/verify-account-routing.module.ts42
-rw-r--r--client/src/app/+verify-account/verify-account.module.ts27
-rw-r--r--client/src/app/app-routing.module.ts4
-rw-r--r--client/src/app/core/server/server.service.ts3
-rw-r--r--client/src/app/login/login.component.html4
-rw-r--r--client/src/app/shared/users/user.service.ts23
-rw-r--r--client/src/app/signup/signup.component.ts22
-rw-r--r--config/default.yaml1
-rw-r--r--config/production.yaml.example1
-rw-r--r--config/test.yaml1
-rw-r--r--server/controllers/api/config.ts16
-rw-r--r--server/controllers/api/users/index.ts47
-rw-r--r--server/helpers/audit-logger.ts1
-rw-r--r--server/helpers/custom-validators/users.ts5
-rw-r--r--server/initializers/checker.ts3
-rw-r--r--server/initializers/constants.ts6
-rw-r--r--server/initializers/installer.ts1
-rw-r--r--server/initializers/migrations/0265-user-email-verified.ts24
-rw-r--r--server/lib/emailer.ts17
-rw-r--r--server/lib/oauth-model.ts5
-rw-r--r--server/lib/redis.ts18
-rw-r--r--server/middlewares/validators/users.ts46
-rw-r--r--server/models/account/user.ts8
-rw-r--r--server/tests/api/check-params/config.ts3
-rw-r--r--server/tests/api/check-params/users.ts22
-rw-r--r--server/tests/api/server/config.ts5
-rw-r--r--server/tests/api/server/email.ts42
-rw-r--r--server/tests/api/users/index.ts1
-rw-r--r--server/tests/api/users/users-verification.ts133
-rw-r--r--server/tests/api/users/users.ts2
-rw-r--r--server/tests/utils/server/config.ts3
-rw-r--r--server/tests/utils/users/users.ts26
-rw-r--r--shared/models/server/custom-config.model.ts1
-rw-r--r--shared/models/server/server-config.model.ts3
42 files changed, 715 insertions, 24 deletions
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
index ca7890d84..a0f0abd10 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
@@ -91,6 +91,11 @@
91 i18n-labelText labelText="Signup enabled" 91 i18n-labelText labelText="Signup enabled"
92 ></my-peertube-checkbox> 92 ></my-peertube-checkbox>
93 93
94 <my-peertube-checkbox *ngIf="isSignupEnabled()"
95 inputName="signupRequiresEmailVerification" formControlName="signupRequiresEmailVerification"
96 i18n-labelText labelText="Signup requires email verification"
97 ></my-peertube-checkbox>
98
94 <div *ngIf="isSignupEnabled()" class="form-group"> 99 <div *ngIf="isSignupEnabled()" class="form-group">
95 <label i18n for="signupLimit">Signup limit</label> 100 <label i18n for="signupLimit">Signup limit</label>
96 <input 101 <input
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index 248b0df50..ce2ea8a6c 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -90,6 +90,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
90 cacheCaptionsSize: this.customConfigValidatorsService.CACHE_CAPTIONS_SIZE, 90 cacheCaptionsSize: this.customConfigValidatorsService.CACHE_CAPTIONS_SIZE,
91 signupEnabled: null, 91 signupEnabled: null,
92 signupLimit: this.customConfigValidatorsService.SIGNUP_LIMIT, 92 signupLimit: this.customConfigValidatorsService.SIGNUP_LIMIT,
93 signupRequiresEmailVerification: null,
93 importVideosHttpEnabled: null, 94 importVideosHttpEnabled: null,
94 importVideosTorrentEnabled: null, 95 importVideosTorrentEnabled: null,
95 adminEmail: this.customConfigValidatorsService.ADMIN_EMAIL, 96 adminEmail: this.customConfigValidatorsService.ADMIN_EMAIL,
@@ -187,7 +188,8 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
187 }, 188 },
188 signup: { 189 signup: {
189 enabled: this.form.value['signupEnabled'], 190 enabled: this.form.value['signupEnabled'],
190 limit: this.form.value['signupLimit'] 191 limit: this.form.value['signupLimit'],
192 requiresEmailVerification: this.form.value['signupRequiresEmailVerification']
191 }, 193 },
192 admin: { 194 admin: {
193 email: this.form.value['adminEmail'] 195 email: this.form.value['adminEmail']
@@ -250,6 +252,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
250 cacheCaptionsSize: this.customConfig.cache.captions.size, 252 cacheCaptionsSize: this.customConfig.cache.captions.size,
251 signupEnabled: this.customConfig.signup.enabled, 253 signupEnabled: this.customConfig.signup.enabled,
252 signupLimit: this.customConfig.signup.limit, 254 signupLimit: this.customConfig.signup.limit,
255 signupRequiresEmailVerification: this.customConfig.signup.requiresEmailVerification,
253 adminEmail: this.customConfig.admin.email, 256 adminEmail: this.customConfig.admin.email,
254 userVideoQuota: this.customConfig.user.videoQuota, 257 userVideoQuota: this.customConfig.user.videoQuota,
255 userVideoQuotaDaily: this.customConfig.user.videoQuotaDaily, 258 userVideoQuotaDaily: this.customConfig.user.videoQuotaDaily,
diff --git a/client/src/app/+verify-account/index.ts b/client/src/app/+verify-account/index.ts
new file mode 100644
index 000000000..733f5ba77
--- /dev/null
+++ b/client/src/app/+verify-account/index.ts
@@ -0,0 +1,2 @@
1export * from '@app/+verify-account/verify-account-routing.module'
2export * from '@app/+verify-account/verify-account.module'
diff --git a/client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html b/client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html
new file mode 100644
index 000000000..2e4180632
--- /dev/null
+++ b/client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html
@@ -0,0 +1,22 @@
1<div class="margin-content">
2 <div i18n class="title-page title-page-single">
3 Request email for account verification
4 </div>
5
6 <form *ngIf="requiresEmailVerification; else emailVerificationNotRequired" role="form" (ngSubmit)="askSendVerifyEmail()" [formGroup]="form">
7 <div class="form-group">
8 <label i18n for="verify-email-email">Email</label>
9 <input
10 type="email" id="verify-email-email" i18n-placeholder placeholder="Email address" required
11 formControlName="verify-email-email" [ngClass]="{ 'input-error': formErrors['verify-email-email'] }"
12 >
13 <div *ngIf="formErrors['verify-email-email']" class="form-error">
14 {{ formErrors['verify-email-email'] }}
15 </div>
16 </div>
17 <input type="submit" i18n-value value="Send verification email" [disabled]="!form.valid">
18 </form>
19 <ng-template #emailVerificationNotRequired>
20 <div i18n>This instance does not require email verification.</div>
21 </ng-template>
22</div>
diff --git a/client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.scss b/client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.scss
new file mode 100644
index 000000000..efec6b706
--- /dev/null
+++ b/client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.scss
@@ -0,0 +1,12 @@
1@import '_variables';
2@import '_mixins';
3
4input:not([type=submit]) {
5 @include peertube-input-text(340px);
6 display: block;
7}
8
9input[type=submit] {
10 @include peertube-button;
11 @include orange-button;
12}
diff --git a/client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts b/client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
new file mode 100644
index 000000000..995f42ffc
--- /dev/null
+++ b/client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
@@ -0,0 +1,58 @@
1import { Component, OnInit } from '@angular/core'
2import { I18n } from '@ngx-translate/i18n-polyfill'
3import { NotificationsService } from 'angular2-notifications'
4import { ServerService } from '@app/core/server'
5import { RedirectService } from '@app/core'
6import { UserService, FormReactive } from '@app/shared'
7import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
8import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
9
10@Component({
11 selector: 'my-verify-account-ask-send-email',
12 templateUrl: './verify-account-ask-send-email.component.html',
13 styleUrls: [ './verify-account-ask-send-email.component.scss' ]
14})
15
16export class VerifyAccountAskSendEmailComponent extends FormReactive implements OnInit {
17
18 constructor (
19 protected formValidatorService: FormValidatorService,
20 private userValidatorsService: UserValidatorsService,
21 private userService: UserService,
22 private serverService: ServerService,
23 private notificationsService: NotificationsService,
24 private redirectService: RedirectService,
25 private i18n: I18n
26 ) {
27 super()
28 }
29
30 get requiresEmailVerification () {
31 return this.serverService.getConfig().signup.requiresEmailVerification
32 }
33
34 ngOnInit () {
35 this.buildForm({
36 'verify-email-email': this.userValidatorsService.USER_EMAIL
37 })
38 }
39
40 askSendVerifyEmail () {
41 const email = this.form.value['verify-email-email']
42 this.userService.askSendVerifyEmail(email)
43 .subscribe(
44 () => {
45 const message = this.i18n(
46 'An email with verification link will be sent to {{email}}.',
47 { email }
48 )
49 this.notificationsService.success(this.i18n('Success'), message)
50 this.redirectService.redirectToHomepage()
51 },
52
53 err => {
54 this.notificationsService.error(this.i18n('Error'), err.message)
55 }
56 )
57 }
58}
diff --git a/client/src/app/+verify-account/verify-account-email/verify-account-email.component.html b/client/src/app/+verify-account/verify-account-email/verify-account-email.component.html
new file mode 100644
index 000000000..30ace5e10
--- /dev/null
+++ b/client/src/app/+verify-account/verify-account-email/verify-account-email.component.html
@@ -0,0 +1,15 @@
1<div class="margin-content">
2 <div i18n class="title-page title-page-single">
3 Verify account email confirmation
4 </div>
5
6 <div i18n *ngIf="success; else verificationError">
7 Your email has been verified and you may now login. Redirecting...
8 </div>
9 <ng-template #verificationError>
10 <div>
11 <span i18n>An error occurred. </span>
12 <a i18n routerLink="/verify-account/ask-email">Request new verification email.</a>
13 </div>
14 </ng-template>
15</div>
diff --git a/client/src/app/+verify-account/verify-account-email/verify-account-email.component.ts b/client/src/app/+verify-account/verify-account-email/verify-account-email.component.ts
new file mode 100644
index 000000000..26b3bf4b1
--- /dev/null
+++ b/client/src/app/+verify-account/verify-account-email/verify-account-email.component.ts
@@ -0,0 +1,54 @@
1import { Component, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router'
3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { NotificationsService } from 'angular2-notifications'
5import { UserService } from '@app/shared'
6
7@Component({
8 selector: 'my-verify-account-email',
9 templateUrl: './verify-account-email.component.html'
10})
11
12export class VerifyAccountEmailComponent implements OnInit {
13 success = false
14
15 private userId: number
16 private verificationString: string
17
18 constructor (
19 private userService: UserService,
20 private notificationsService: NotificationsService,
21 private router: Router,
22 private route: ActivatedRoute,
23 private i18n: I18n
24 ) {
25 }
26
27 ngOnInit () {
28
29 this.userId = this.route.snapshot.queryParams['userId']
30 this.verificationString = this.route.snapshot.queryParams['verificationString']
31
32 if (!this.userId || !this.verificationString) {
33 this.notificationsService.error(this.i18n('Error'), this.i18n('Unable to find user id or verification string.'))
34 } else {
35 this.verifyEmail()
36 }
37 }
38
39 verifyEmail () {
40 this.userService.verifyEmail(this.userId, this.verificationString)
41 .subscribe(
42 () => {
43 this.success = true
44 setTimeout(() => {
45 this.router.navigate([ '/login' ])
46 }, 2000)
47 },
48
49 err => {
50 this.notificationsService.error(this.i18n('Error'), err.message)
51 }
52 )
53 }
54}
diff --git a/client/src/app/+verify-account/verify-account-routing.module.ts b/client/src/app/+verify-account/verify-account-routing.module.ts
new file mode 100644
index 000000000..a038f0336
--- /dev/null
+++ b/client/src/app/+verify-account/verify-account-routing.module.ts
@@ -0,0 +1,42 @@
1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router'
3
4import { MetaGuard } from '@ngx-meta/core'
5
6import { VerifyAccountEmailComponent } from '@app/+verify-account/verify-account-email/verify-account-email.component'
7import {
8 VerifyAccountAskSendEmailComponent
9} from '@app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component'
10
11const verifyAccountRoutes: Routes = [
12 {
13 path: '',
14 canActivateChild: [ MetaGuard ],
15 children: [
16 {
17 path: 'email',
18 component: VerifyAccountEmailComponent,
19 data: {
20 meta: {
21 title: 'Verify account email'
22 }
23 }
24 },
25 {
26 path: 'ask-send-email',
27 component: VerifyAccountAskSendEmailComponent,
28 data: {
29 meta: {
30 title: 'Verify account ask send email'
31 }
32 }
33 }
34 ]
35 }
36]
37
38@NgModule({
39 imports: [ RouterModule.forChild(verifyAccountRoutes) ],
40 exports: [ RouterModule ]
41})
42export class VerifyAccountRoutingModule {}
diff --git a/client/src/app/+verify-account/verify-account.module.ts b/client/src/app/+verify-account/verify-account.module.ts
new file mode 100644
index 000000000..9092c6b4f
--- /dev/null
+++ b/client/src/app/+verify-account/verify-account.module.ts
@@ -0,0 +1,27 @@
1import { NgModule } from '@angular/core'
2
3import { VerifyAccountRoutingModule } from '@app/+verify-account/verify-account-routing.module'
4import { VerifyAccountEmailComponent } from '@app/+verify-account/verify-account-email/verify-account-email.component'
5import {
6 VerifyAccountAskSendEmailComponent
7} from '@app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component'
8import { SharedModule } from '@app/shared'
9
10@NgModule({
11 imports: [
12 VerifyAccountRoutingModule,
13 SharedModule
14 ],
15
16 declarations: [
17 VerifyAccountEmailComponent,
18 VerifyAccountAskSendEmailComponent
19 ],
20
21 exports: [
22 ],
23
24 providers: [
25 ]
26})
27export class VerifyAccountModule { }
diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts
index 30e615b3e..545d6aeda 100644
--- a/client/src/app/app-routing.module.ts
+++ b/client/src/app/app-routing.module.ts
@@ -14,6 +14,10 @@ const routes: Routes = [
14 loadChildren: './+my-account/my-account.module#MyAccountModule' 14 loadChildren: './+my-account/my-account.module#MyAccountModule'
15 }, 15 },
16 { 16 {
17 path: 'verify-account',
18 loadChildren: './+verify-account/verify-account.module#VerifyAccountModule'
19 },
20 {
17 path: 'accounts', 21 path: 'accounts',
18 loadChildren: './+accounts/accounts.module#AccountsModule' 22 loadChildren: './+accounts/accounts.module#AccountsModule'
19 }, 23 },
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts
index a1ce12069..e7152efa0 100644
--- a/client/src/app/core/server/server.service.ts
+++ b/client/src/app/core/server/server.service.ts
@@ -40,7 +40,8 @@ export class ServerService {
40 serverVersion: 'Unknown', 40 serverVersion: 'Unknown',
41 signup: { 41 signup: {
42 allowed: false, 42 allowed: false,
43 allowedForCurrentIP: false 43 allowedForCurrentIP: false,
44 requiresEmailVerification: false
44 }, 45 },
45 transcoding: { 46 transcoding: {
46 enabledResolutions: [] 47 enabledResolutions: []
diff --git a/client/src/app/login/login.component.html b/client/src/app/login/login.component.html
index 3a6d61327..619150ade 100644
--- a/client/src/app/login/login.component.html
+++ b/client/src/app/login/login.component.html
@@ -3,7 +3,9 @@
3 Login 3 Login
4 </div> 4 </div>
5 5
6 <div *ngIf="error" class="alert alert-danger">{{ error }}</div> 6 <div *ngIf="error" class="alert alert-danger">{{ error }}
7 <span *ngIf="error === 'User email is not verified.'"> <a i18n routerLink="/verify-account/ask-send-email">Request new verification email.</a></span>
8 </div>
7 9
8 <form role="form" (ngSubmit)="login()" [formGroup]="form"> 10 <form role="form" (ngSubmit)="login()" [formGroup]="form">
9 <div class="form-group"> 11 <div class="form-group">
diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts
index e6dc3dbf8..249c589b7 100644
--- a/client/src/app/shared/users/user.service.ts
+++ b/client/src/app/shared/users/user.service.ts
@@ -94,4 +94,27 @@ export class UserService {
94 catchError(res => this.restExtractor.handleError(res)) 94 catchError(res => this.restExtractor.handleError(res))
95 ) 95 )
96 } 96 }
97
98 verifyEmail (userId: number, verificationString: string) {
99 const url = `${UserService.BASE_USERS_URL}/${userId}/verify-email`
100 const body = {
101 verificationString
102 }
103
104 return this.authHttp.post(url, body)
105 .pipe(
106 map(this.restExtractor.extractDataBool),
107 catchError(res => this.restExtractor.handleError(res))
108 )
109 }
110
111 askSendVerifyEmail (email: string) {
112 const url = UserService.BASE_USERS_URL + '/ask-send-verify-email'
113
114 return this.authHttp.post(url, { email })
115 .pipe(
116 map(this.restExtractor.extractDataBool),
117 catchError(err => this.restExtractor.handleError(err))
118 )
119 }
97} 120}
diff --git a/client/src/app/signup/signup.component.ts b/client/src/app/signup/signup.component.ts
index 47f9bc6f4..16e444678 100644
--- a/client/src/app/signup/signup.component.ts
+++ b/client/src/app/signup/signup.component.ts
@@ -3,7 +3,7 @@ import { Router } from '@angular/router'
3import { NotificationsService } from 'angular2-notifications' 3import { NotificationsService } from 'angular2-notifications'
4import { UserCreate } from '../../../../shared' 4import { UserCreate } from '../../../../shared'
5import { FormReactive, UserService, UserValidatorsService } from '../shared' 5import { FormReactive, UserService, UserValidatorsService } from '../shared'
6import { RedirectService } from '@app/core' 6import { RedirectService, ServerService } from '@app/core'
7import { I18n } from '@ngx-translate/i18n-polyfill' 7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' 8import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
9 9
@@ -21,6 +21,7 @@ export class SignupComponent extends FormReactive implements OnInit {
21 private router: Router, 21 private router: Router,
22 private notificationsService: NotificationsService, 22 private notificationsService: NotificationsService,
23 private userService: UserService, 23 private userService: UserService,
24 private serverService: ServerService,
24 private redirectService: RedirectService, 25 private redirectService: RedirectService,
25 private i18n: I18n 26 private i18n: I18n
26 ) { 27 ) {
@@ -31,6 +32,10 @@ export class SignupComponent extends FormReactive implements OnInit {
31 return window.location.host 32 return window.location.host
32 } 33 }
33 34
35 get requiresEmailVerification () {
36 return this.serverService.getConfig().signup.requiresEmailVerification
37 }
38
34 ngOnInit () { 39 ngOnInit () {
35 this.buildForm({ 40 this.buildForm({
36 username: this.userValidatorsService.USER_USERNAME, 41 username: this.userValidatorsService.USER_USERNAME,
@@ -47,10 +52,17 @@ export class SignupComponent extends FormReactive implements OnInit {
47 52
48 this.userService.signup(userCreate).subscribe( 53 this.userService.signup(userCreate).subscribe(
49 () => { 54 () => {
50 this.notificationsService.success( 55 if (this.requiresEmailVerification) {
51 this.i18n('Success'), 56 this.notificationsService.alert(
52 this.i18n('Registration for {{username}} complete.', { username: userCreate.username }) 57 this.i18n('Welcome'),
53 ) 58 this.i18n('Please check your email to verify your account and complete signup.')
59 )
60 } else {
61 this.notificationsService.success(
62 this.i18n('Success'),
63 this.i18n('Registration for {{username}} complete.', { username: userCreate.username })
64 )
65 }
54 this.redirectService.redirectToHomepage() 66 this.redirectService.redirectToHomepage()
55 }, 67 },
56 68
diff --git a/config/default.yaml b/config/default.yaml
index 7799ea927..ef63fbd28 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -74,6 +74,7 @@ admin:
74signup: 74signup:
75 enabled: false 75 enabled: false
76 limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited 76 limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited
77 requires_email_verification: false
77 filters: 78 filters:
78 cidr: # You can specify CIDR ranges to whitelist (empty = no filtering) or blacklist 79 cidr: # You can specify CIDR ranges to whitelist (empty = no filtering) or blacklist
79 whitelist: [] 80 whitelist: []
diff --git a/config/production.yaml.example b/config/production.yaml.example
index 33a26dec1..f7b153698 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -87,6 +87,7 @@ admin:
87signup: 87signup:
88 enabled: false 88 enabled: false
89 limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited 89 limit: 10 # When the limit is reached, registrations are disabled. -1 == unlimited
90 requires_email_verification: false
90 filters: 91 filters:
91 cidr: # You can specify CIDR ranges to whitelist (empty = no filtering) or blacklist 92 cidr: # You can specify CIDR ranges to whitelist (empty = no filtering) or blacklist
92 whitelist: [] 93 whitelist: []
diff --git a/config/test.yaml b/config/test.yaml
index 879b6bdd4..6a8e47aac 100644
--- a/config/test.yaml
+++ b/config/test.yaml
@@ -29,6 +29,7 @@ cache:
29 29
30signup: 30signup:
31 enabled: true 31 enabled: true
32 requires_email_verification: false
32 33
33transcoding: 34transcoding:
34 enabled: true 35 enabled: true
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index 25ddd1fa6..6edbe4820 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -60,7 +60,8 @@ async function getConfig (req: express.Request, res: express.Response, next: exp
60 serverVersion: packageJSON.version, 60 serverVersion: packageJSON.version,
61 signup: { 61 signup: {
62 allowed, 62 allowed,
63 allowedForCurrentIP 63 allowedForCurrentIP,
64 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
64 }, 65 },
65 transcoding: { 66 transcoding: {
66 enabledResolutions 67 enabledResolutions
@@ -159,12 +160,20 @@ async function updateCustomConfig (req: express.Request, res: express.Response,
159 toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10) 160 toUpdate.transcoding.threads = parseInt('' + toUpdate.transcoding.threads, 10)
160 161
161 // camelCase to snake_case key 162 // camelCase to snake_case key
162 const toUpdateJSON = omit(toUpdate, 'user.videoQuota', 'instance.defaultClientRoute', 'instance.shortDescription', 'cache.videoCaptions') 163 const toUpdateJSON = omit(
164 toUpdate,
165 'user.videoQuota',
166 'instance.defaultClientRoute',
167 'instance.shortDescription',
168 'cache.videoCaptions',
169 'signup.requiresEmailVerification'
170 )
163 toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota 171 toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota
164 toUpdateJSON.user['video_quota_daily'] = toUpdate.user.videoQuotaDaily 172 toUpdateJSON.user['video_quota_daily'] = toUpdate.user.videoQuotaDaily
165 toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute 173 toUpdateJSON.instance['default_client_route'] = toUpdate.instance.defaultClientRoute
166 toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription 174 toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription
167 toUpdateJSON.instance['default_nsfw_policy'] = toUpdate.instance.defaultNSFWPolicy 175 toUpdateJSON.instance['default_nsfw_policy'] = toUpdate.instance.defaultNSFWPolicy
176 toUpdateJSON.signup['requires_email_verification'] = toUpdate.signup.requiresEmailVerification
168 177
169 await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 }) 178 await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 })
170 179
@@ -220,7 +229,8 @@ function customConfig (): CustomConfig {
220 }, 229 },
221 signup: { 230 signup: {
222 enabled: CONFIG.SIGNUP.ENABLED, 231 enabled: CONFIG.SIGNUP.ENABLED,
223 limit: CONFIG.SIGNUP.LIMIT 232 limit: CONFIG.SIGNUP.LIMIT,
233 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
224 }, 234 },
225 admin: { 235 admin: {
226 email: CONFIG.ADMIN.EMAIL 236 email: CONFIG.ADMIN.EMAIL
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
index 25d51ae5e..008c34ca4 100644
--- a/server/controllers/api/users/index.ts
+++ b/server/controllers/api/users/index.ts
@@ -25,7 +25,10 @@ import {
25 usersSortValidator, 25 usersSortValidator,
26 usersUpdateValidator 26 usersUpdateValidator
27} from '../../../middlewares' 27} from '../../../middlewares'
28import { usersAskResetPasswordValidator, usersBlockingValidator, usersResetPasswordValidator } from '../../../middlewares/validators' 28import {
29 usersAskResetPasswordValidator, usersBlockingValidator, usersResetPasswordValidator,
30 usersAskSendVerifyEmailValidator, usersVerifyEmailValidator
31} from '../../../middlewares/validators'
29import { UserModel } from '../../../models/account/user' 32import { UserModel } from '../../../models/account/user'
30import { OAuthTokenModel } from '../../../models/oauth/oauth-token' 33import { OAuthTokenModel } from '../../../models/oauth/oauth-token'
31import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger' 34import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger'
@@ -110,6 +113,17 @@ usersRouter.post('/:id/reset-password',
110 asyncMiddleware(resetUserPassword) 113 asyncMiddleware(resetUserPassword)
111) 114)
112 115
116usersRouter.post('/ask-send-verify-email',
117 loginRateLimiter,
118 asyncMiddleware(usersAskSendVerifyEmailValidator),
119 asyncMiddleware(askSendVerifyUserEmail)
120)
121
122usersRouter.post('/:id/verify-email',
123 asyncMiddleware(usersVerifyEmailValidator),
124 asyncMiddleware(verifyUserEmail)
125)
126
113usersRouter.post('/token', 127usersRouter.post('/token',
114 loginRateLimiter, 128 loginRateLimiter,
115 token, 129 token,
@@ -165,7 +179,8 @@ async function registerUser (req: express.Request, res: express.Response) {
165 autoPlayVideo: true, 179 autoPlayVideo: true,
166 role: UserRole.USER, 180 role: UserRole.USER,
167 videoQuota: CONFIG.USER.VIDEO_QUOTA, 181 videoQuota: CONFIG.USER.VIDEO_QUOTA,
168 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY 182 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY,
183 emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
169 }) 184 })
170 185
171 const { user } = await createUserAccountAndChannel(userToCreate) 186 const { user } = await createUserAccountAndChannel(userToCreate)
@@ -173,6 +188,10 @@ async function registerUser (req: express.Request, res: express.Response) {
173 auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON())) 188 auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON()))
174 logger.info('User %s with its channel and account registered.', body.username) 189 logger.info('User %s with its channel and account registered.', body.username)
175 190
191 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
192 await sendVerifyUserEmail(user)
193 }
194
176 return res.type('json').status(204).end() 195 return res.type('json').status(204).end()
177} 196}
178 197
@@ -261,6 +280,30 @@ async function resetUserPassword (req: express.Request, res: express.Response, n
261 return res.status(204).end() 280 return res.status(204).end()
262} 281}
263 282
283async function sendVerifyUserEmail (user: UserModel) {
284 const verificationString = await Redis.Instance.setVerifyEmailVerificationString(user.id)
285 const url = CONFIG.WEBSERVER.URL + '/verify-account/email?userId=' + user.id + '&verificationString=' + verificationString
286 await Emailer.Instance.addVerifyEmailJob(user.email, url)
287 return
288}
289
290async function askSendVerifyUserEmail (req: express.Request, res: express.Response, next: express.NextFunction) {
291 const user = res.locals.user as UserModel
292
293 await sendVerifyUserEmail(user)
294
295 return res.status(204).end()
296}
297
298async function verifyUserEmail (req: express.Request, res: express.Response, next: express.NextFunction) {
299 const user = res.locals.user as UserModel
300 user.emailVerified = true
301
302 await user.save()
303
304 return res.status(204).end()
305}
306
264function success (req: express.Request, res: express.Response, next: express.NextFunction) { 307function success (req: express.Request, res: express.Response, next: express.NextFunction) {
265 res.end() 308 res.end()
266} 309}
diff --git a/server/helpers/audit-logger.ts b/server/helpers/audit-logger.ts
index db20df20f..7db72b69c 100644
--- a/server/helpers/audit-logger.ts
+++ b/server/helpers/audit-logger.ts
@@ -234,6 +234,7 @@ const customConfigKeysToKeep = [
234 'cache-captions-size', 234 'cache-captions-size',
235 'signup-enabled', 235 'signup-enabled',
236 'signup-limit', 236 'signup-limit',
237 'signup-requiresEmailVerification',
237 'admin-email', 238 'admin-email',
238 'user-videoQuota', 239 'user-videoQuota',
239 'transcoding-enabled', 240 'transcoding-enabled',
diff --git a/server/helpers/custom-validators/users.ts b/server/helpers/custom-validators/users.ts
index 8d6247e41..90fc74a48 100644
--- a/server/helpers/custom-validators/users.ts
+++ b/server/helpers/custom-validators/users.ts
@@ -33,6 +33,10 @@ function isUserDescriptionValid (value: string) {
33 return value === null || (exists(value) && validator.isLength(value, CONSTRAINTS_FIELDS.USERS.DESCRIPTION)) 33 return value === null || (exists(value) && validator.isLength(value, CONSTRAINTS_FIELDS.USERS.DESCRIPTION))
34} 34}
35 35
36function isUserEmailVerifiedValid (value: any) {
37 return isBooleanValid(value)
38}
39
36const nsfwPolicies = values(NSFW_POLICY_TYPES) 40const nsfwPolicies = values(NSFW_POLICY_TYPES)
37function isUserNSFWPolicyValid (value: any) { 41function isUserNSFWPolicyValid (value: any) {
38 return exists(value) && nsfwPolicies.indexOf(value) !== -1 42 return exists(value) && nsfwPolicies.indexOf(value) !== -1
@@ -72,6 +76,7 @@ export {
72 isUserVideoQuotaValid, 76 isUserVideoQuotaValid,
73 isUserVideoQuotaDailyValid, 77 isUserVideoQuotaDailyValid,
74 isUserUsernameValid, 78 isUserUsernameValid,
79 isUserEmailVerifiedValid,
75 isUserNSFWPolicyValid, 80 isUserNSFWPolicyValid,
76 isUserAutoPlayVideoValid, 81 isUserAutoPlayVideoValid,
77 isUserDisplayNameValid, 82 isUserDisplayNameValid,
diff --git a/server/initializers/checker.ts b/server/initializers/checker.ts
index 916e9067e..ee02ecf48 100644
--- a/server/initializers/checker.ts
+++ b/server/initializers/checker.ts
@@ -49,7 +49,8 @@ function checkMissedConfig () {
49 'log.level', 49 'log.level',
50 'user.video_quota', 'user.video_quota_daily', 50 'user.video_quota', 'user.video_quota_daily',
51 'cache.previews.size', 'admin.email', 51 'cache.previews.size', 'admin.email',
52 'signup.enabled', 'signup.limit', 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', 52 'signup.enabled', 'signup.limit', 'signup.requires_email_verification',
53 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
53 'transcoding.enabled', 'transcoding.threads', 54 'transcoding.enabled', 'transcoding.threads',
54 'import.videos.http.enabled', 55 'import.videos.http.enabled',
55 'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route', 56 'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route',
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 2d9a2e670..5d93c6b82 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -15,7 +15,7 @@ let config: IConfig = require('config')
15 15
16// --------------------------------------------------------------------------- 16// ---------------------------------------------------------------------------
17 17
18const LAST_MIGRATION_VERSION = 260 18const LAST_MIGRATION_VERSION = 265
19 19
20// --------------------------------------------------------------------------- 20// ---------------------------------------------------------------------------
21 21
@@ -204,6 +204,7 @@ const CONFIG = {
204 SIGNUP: { 204 SIGNUP: {
205 get ENABLED () { return config.get<boolean>('signup.enabled') }, 205 get ENABLED () { return config.get<boolean>('signup.enabled') },
206 get LIMIT () { return config.get<number>('signup.limit') }, 206 get LIMIT () { return config.get<number>('signup.limit') },
207 get REQUIRES_EMAIL_VERIFICATION () { return config.get<boolean>('signup.requires_email_verification') },
207 FILTERS: { 208 FILTERS: {
208 CIDR: { 209 CIDR: {
209 get WHITELIST () { return config.get<string[]>('signup.filters.cidr.whitelist') }, 210 get WHITELIST () { return config.get<string[]>('signup.filters.cidr.whitelist') },
@@ -500,6 +501,8 @@ const BCRYPT_SALT_SIZE = 10
500 501
501const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes 502const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes
502 503
504const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
505
503const NSFW_POLICY_TYPES: { [ id: string]: NSFWPolicyType } = { 506const NSFW_POLICY_TYPES: { [ id: string]: NSFWPolicyType } = {
504 DO_NOT_LIST: 'do_not_list', 507 DO_NOT_LIST: 'do_not_list',
505 BLUR: 'blur', 508 BLUR: 'blur',
@@ -661,6 +664,7 @@ export {
661 VIDEO_ABUSE_STATES, 664 VIDEO_ABUSE_STATES,
662 JOB_REQUEST_TIMEOUT, 665 JOB_REQUEST_TIMEOUT,
663 USER_PASSWORD_RESET_LIFETIME, 666 USER_PASSWORD_RESET_LIFETIME,
667 USER_EMAIL_VERIFY_LIFETIME,
664 IMAGE_MIMETYPE_EXT, 668 IMAGE_MIMETYPE_EXT,
665 SCHEDULER_INTERVALS_MS, 669 SCHEDULER_INTERVALS_MS,
666 REPEAT_JOBS, 670 REPEAT_JOBS,
diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts
index d4aaec8fe..818bb04a2 100644
--- a/server/initializers/installer.ts
+++ b/server/initializers/installer.ts
@@ -122,6 +122,7 @@ async function createOAuthAdminIfNotExist () {
122 email, 122 email,
123 password, 123 password,
124 role, 124 role,
125 verified: true,
125 nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY, 126 nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
126 videoQuota: -1, 127 videoQuota: -1,
127 videoQuotaDaily: -1 128 videoQuotaDaily: -1
diff --git a/server/initializers/migrations/0265-user-email-verified.ts b/server/initializers/migrations/0265-user-email-verified.ts
new file mode 100644
index 000000000..59dfdad2b
--- /dev/null
+++ b/server/initializers/migrations/0265-user-email-verified.ts
@@ -0,0 +1,24 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7}): Promise<any> {
8 {
9 const data = {
10 type: Sequelize.BOOLEAN,
11 allowNull: true,
12 defaultValue: null
13 }
14
15 await utils.queryInterface.addColumn('user', 'emailVerified', data)
16 }
17
18}
19
20function down (options) {
21 throw new Error('Not implemented.')
22}
23
24export { up, down }
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts
index bf8e5b6c3..9327792fb 100644
--- a/server/lib/emailer.ts
+++ b/server/lib/emailer.ts
@@ -89,6 +89,23 @@ class Emailer {
89 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 89 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
90 } 90 }
91 91
92 addVerifyEmailJob (to: string, verifyEmailUrl: string) {
93 const text = `Welcome to PeerTube,\n\n` +
94 `To start using PeerTube on ${CONFIG.WEBSERVER.HOST} you must verify your email! ` +
95 `Please follow this link to verify this email belongs to you: ${verifyEmailUrl}\n\n` +
96 `If you are not the person who initiated this request, please ignore this email.\n\n` +
97 `Cheers,\n` +
98 `PeerTube.`
99
100 const emailPayload: EmailPayload = {
101 to: [ to ],
102 subject: 'Verify your PeerTube email',
103 text
104 }
105
106 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
107 }
108
92 async addVideoAbuseReportJob (videoId: number) { 109 async addVideoAbuseReportJob (videoId: number) {
93 const video = await VideoModel.load(videoId) 110 const video = await VideoModel.load(videoId)
94 if (!video) throw new Error('Unknown Video id during Abuse report.') 111 if (!video) throw new Error('Unknown Video id during Abuse report.')
diff --git a/server/lib/oauth-model.ts b/server/lib/oauth-model.ts
index 09eaf75d1..2f8667e19 100644
--- a/server/lib/oauth-model.ts
+++ b/server/lib/oauth-model.ts
@@ -3,6 +3,7 @@ import { logger } from '../helpers/logger'
3import { UserModel } from '../models/account/user' 3import { UserModel } from '../models/account/user'
4import { OAuthClientModel } from '../models/oauth/oauth-client' 4import { OAuthClientModel } from '../models/oauth/oauth-client'
5import { OAuthTokenModel } from '../models/oauth/oauth-token' 5import { OAuthTokenModel } from '../models/oauth/oauth-token'
6import { CONFIG } from '../initializers/constants'
6 7
7type TokenInfo = { accessToken: string, refreshToken: string, accessTokenExpiresAt: Date, refreshTokenExpiresAt: Date } 8type TokenInfo = { accessToken: string, refreshToken: string, accessTokenExpiresAt: Date, refreshTokenExpiresAt: Date }
8 9
@@ -37,6 +38,10 @@ async function getUser (usernameOrEmail: string, password: string) {
37 38
38 if (user.blocked) throw new AccessDeniedError('User is blocked.') 39 if (user.blocked) throw new AccessDeniedError('User is blocked.')
39 40
41 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION && user.emailVerified === false) {
42 throw new AccessDeniedError('User email is not verified.')
43 }
44
40 return user 45 return user
41} 46}
42 47
diff --git a/server/lib/redis.ts b/server/lib/redis.ts
index 0b4b41e4e..e4e435659 100644
--- a/server/lib/redis.ts
+++ b/server/lib/redis.ts
@@ -2,7 +2,7 @@ import * as express from 'express'
2import { createClient, RedisClient } from 'redis' 2import { createClient, RedisClient } from 'redis'
3import { logger } from '../helpers/logger' 3import { logger } from '../helpers/logger'
4import { generateRandomString } from '../helpers/utils' 4import { generateRandomString } from '../helpers/utils'
5import { CONFIG, USER_PASSWORD_RESET_LIFETIME, VIDEO_VIEW_LIFETIME } from '../initializers' 5import { CONFIG, USER_PASSWORD_RESET_LIFETIME, USER_EMAIL_VERIFY_LIFETIME, VIDEO_VIEW_LIFETIME } from '../initializers'
6 6
7type CachedRoute = { 7type CachedRoute = {
8 body: string, 8 body: string,
@@ -60,6 +60,18 @@ class Redis {
60 return this.getValue(this.generateResetPasswordKey(userId)) 60 return this.getValue(this.generateResetPasswordKey(userId))
61 } 61 }
62 62
63 async setVerifyEmailVerificationString (userId: number) {
64 const generatedString = await generateRandomString(32)
65
66 await this.setValue(this.generateVerifyEmailKey(userId), generatedString, USER_EMAIL_VERIFY_LIFETIME)
67
68 return generatedString
69 }
70
71 async getVerifyEmailLink (userId: number) {
72 return this.getValue(this.generateVerifyEmailKey(userId))
73 }
74
63 setIPVideoView (ip: string, videoUUID: string) { 75 setIPVideoView (ip: string, videoUUID: string) {
64 return this.setValue(this.buildViewKey(ip, videoUUID), '1', VIDEO_VIEW_LIFETIME) 76 return this.setValue(this.buildViewKey(ip, videoUUID), '1', VIDEO_VIEW_LIFETIME)
65 } 77 }
@@ -135,6 +147,10 @@ class Redis {
135 return 'reset-password-' + userId 147 return 'reset-password-' + userId
136 } 148 }
137 149
150 generateVerifyEmailKey (userId: number) {
151 return 'verify-email-' + userId
152 }
153
138 buildViewKey (ip: string, videoUUID: string) { 154 buildViewKey (ip: string, videoUUID: string) {
139 return videoUUID + '-' + ip 155 return videoUUID + '-' + ip
140 } 156 }
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index 6c5e783e9..a595c39ec 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -248,6 +248,48 @@ const usersResetPasswordValidator = [
248 } 248 }
249] 249]
250 250
251const usersAskSendVerifyEmailValidator = [
252 body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
253
254 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
255 logger.debug('Checking askUsersSendVerifyEmail parameters', { parameters: req.body })
256
257 if (areValidationErrors(req, res)) return
258 const exists = await checkUserEmailExist(req.body.email, res, false)
259 if (!exists) {
260 logger.debug('User with email %s does not exist (asking verify email).', req.body.email)
261 // Do not leak our emails
262 return res.status(204).end()
263 }
264
265 return next()
266 }
267]
268
269const usersVerifyEmailValidator = [
270 param('id').isInt().not().isEmpty().withMessage('Should have a valid id'),
271 body('verificationString').not().isEmpty().withMessage('Should have a valid verification string'),
272
273 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
274 logger.debug('Checking usersVerifyEmail parameters', { parameters: req.params })
275
276 if (areValidationErrors(req, res)) return
277 if (!await checkUserIdExist(req.params.id, res)) return
278
279 const user = res.locals.user as UserModel
280 const redisVerificationString = await Redis.Instance.getVerifyEmailLink(user.id)
281
282 if (redisVerificationString !== req.body.verificationString) {
283 return res
284 .status(403)
285 .send({ error: 'Invalid verification string.' })
286 .end()
287 }
288
289 return next()
290 }
291]
292
251// --------------------------------------------------------------------------- 293// ---------------------------------------------------------------------------
252 294
253export { 295export {
@@ -263,7 +305,9 @@ export {
263 ensureUserRegistrationAllowedForIP, 305 ensureUserRegistrationAllowedForIP,
264 usersGetValidator, 306 usersGetValidator,
265 usersAskResetPasswordValidator, 307 usersAskResetPasswordValidator,
266 usersResetPasswordValidator 308 usersResetPasswordValidator,
309 usersAskSendVerifyEmailValidator,
310 usersVerifyEmailValidator
267} 311}
268 312
269// --------------------------------------------------------------------------- 313// ---------------------------------------------------------------------------
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index bae683b12..89265774b 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -24,6 +24,7 @@ import {
24 isUserBlockedReasonValid, 24 isUserBlockedReasonValid,
25 isUserBlockedValid, 25 isUserBlockedValid,
26 isUserNSFWPolicyValid, 26 isUserNSFWPolicyValid,
27 isUserEmailVerifiedValid,
27 isUserPasswordValid, 28 isUserPasswordValid,
28 isUserRoleValid, 29 isUserRoleValid,
29 isUserUsernameValid, 30 isUserUsernameValid,
@@ -92,6 +93,12 @@ export class UserModel extends Model<UserModel> {
92 @Column(DataType.STRING(400)) 93 @Column(DataType.STRING(400))
93 email: string 94 email: string
94 95
96 @AllowNull(true)
97 @Default(null)
98 @Is('UserEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean'))
99 @Column
100 emailVerified: boolean
101
95 @AllowNull(false) 102 @AllowNull(false)
96 @Is('UserNSFWPolicy', value => throwIfNotValid(value, isUserNSFWPolicyValid, 'NSFW policy')) 103 @Is('UserNSFWPolicy', value => throwIfNotValid(value, isUserNSFWPolicyValid, 'NSFW policy'))
97 @Column(DataType.ENUM(values(NSFW_POLICY_TYPES))) 104 @Column(DataType.ENUM(values(NSFW_POLICY_TYPES)))
@@ -304,6 +311,7 @@ export class UserModel extends Model<UserModel> {
304 id: this.id, 311 id: this.id,
305 username: this.username, 312 username: this.username,
306 email: this.email, 313 email: this.email,
314 emailVerified: this.emailVerified,
307 nsfwPolicy: this.nsfwPolicy, 315 nsfwPolicy: this.nsfwPolicy,
308 autoPlayVideo: this.autoPlayVideo, 316 autoPlayVideo: this.autoPlayVideo,
309 role: this.role, 317 role: this.role,
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index ecfb76d47..d807f910b 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -42,7 +42,8 @@ describe('Test config API validators', function () {
42 }, 42 },
43 signup: { 43 signup: {
44 enabled: false, 44 enabled: false,
45 limit: 5 45 limit: 5,
46 requiresEmailVerification: false
46 }, 47 },
47 admin: { 48 admin: {
48 email: 'superadmin1@example.com' 49 email: 'superadmin1@example.com'
diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts
index 8b2ed1b04..95903c8a5 100644
--- a/server/tests/api/check-params/users.ts
+++ b/server/tests/api/check-params/users.ts
@@ -737,6 +737,28 @@ describe('Test users API validators', function () {
737 }) 737 })
738 }) 738 })
739 739
740 describe('When asking for an account verification email', function () {
741 const path = '/api/v1/users/ask-send-verify-email'
742
743 it('Should fail with a missing email', async function () {
744 const fields = {}
745
746 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
747 })
748
749 it('Should fail with an invalid email', async function () {
750 const fields = { email: 'hello' }
751
752 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
753 })
754
755 it('Should succeed with the correct params', async function () {
756 const fields = { email: 'admin@example.com' }
757
758 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 204 })
759 })
760 })
761
740 after(async function () { 762 after(async function () {
741 killallServers([ server, serverWithRegistrationDisabled ]) 763 killallServers([ server, serverWithRegistrationDisabled ])
742 764
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index ece4118a6..facd1688d 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -35,6 +35,7 @@ function checkInitialConfig (data: CustomConfig) {
35 expect(data.cache.captions.size).to.equal(1) 35 expect(data.cache.captions.size).to.equal(1)
36 expect(data.signup.enabled).to.be.true 36 expect(data.signup.enabled).to.be.true
37 expect(data.signup.limit).to.equal(4) 37 expect(data.signup.limit).to.equal(4)
38 expect(data.signup.requiresEmailVerification).to.be.false
38 expect(data.admin.email).to.equal('admin1@example.com') 39 expect(data.admin.email).to.equal('admin1@example.com')
39 expect(data.user.videoQuota).to.equal(5242880) 40 expect(data.user.videoQuota).to.equal(5242880)
40 expect(data.user.videoQuotaDaily).to.equal(-1) 41 expect(data.user.videoQuotaDaily).to.equal(-1)
@@ -64,6 +65,7 @@ function checkUpdatedConfig (data: CustomConfig) {
64 expect(data.cache.captions.size).to.equal(3) 65 expect(data.cache.captions.size).to.equal(3)
65 expect(data.signup.enabled).to.be.false 66 expect(data.signup.enabled).to.be.false
66 expect(data.signup.limit).to.equal(5) 67 expect(data.signup.limit).to.equal(5)
68 expect(data.signup.requiresEmailVerification).to.be.true
67 expect(data.admin.email).to.equal('superadmin1@example.com') 69 expect(data.admin.email).to.equal('superadmin1@example.com')
68 expect(data.user.videoQuota).to.equal(5242881) 70 expect(data.user.videoQuota).to.equal(5242881)
69 expect(data.user.videoQuotaDaily).to.equal(318742) 71 expect(data.user.videoQuotaDaily).to.equal(318742)
@@ -148,7 +150,8 @@ describe('Test config', function () {
148 }, 150 },
149 signup: { 151 signup: {
150 enabled: false, 152 enabled: false,
151 limit: 5 153 limit: 5,
154 requiresEmailVerification: true
152 }, 155 },
153 admin: { 156 admin: {
154 email: 'superadmin1@example.com' 157 email: 'superadmin1@example.com'
diff --git a/server/tests/api/server/email.ts b/server/tests/api/server/email.ts
index db937f288..713a27143 100644
--- a/server/tests/api/server/email.ts
+++ b/server/tests/api/server/email.ts
@@ -5,6 +5,7 @@ import 'mocha'
5import { 5import {
6 addVideoToBlacklist, 6 addVideoToBlacklist,
7 askResetPassword, 7 askResetPassword,
8 askSendVerifyEmail,
8 blockUser, 9 blockUser,
9 createUser, removeVideoFromBlacklist, 10 createUser, removeVideoFromBlacklist,
10 reportVideoAbuse, 11 reportVideoAbuse,
@@ -12,7 +13,8 @@ import {
12 runServer, 13 runServer,
13 unblockUser, 14 unblockUser,
14 uploadVideo, 15 uploadVideo,
15 userLogin 16 userLogin,
17 verifyEmail
16} from '../../utils' 18} from '../../utils'
17import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index' 19import { flushTests, killallServers, ServerInfo, setAccessTokensToServers } from '../../utils/index'
18import { mockSmtpServer } from '../../utils/miscs/email' 20import { mockSmtpServer } from '../../utils/miscs/email'
@@ -207,6 +209,44 @@ describe('Test emails', function () {
207 }) 209 })
208 }) 210 })
209 211
212 describe('When verifying a user email', function () {
213
214 it('Should ask to send the verification email', async function () {
215 this.timeout(10000)
216
217 await askSendVerifyEmail(server.url, 'user_1@example.com')
218
219 await waitJobs(server)
220 expect(emails).to.have.lengthOf(7)
221
222 const email = emails[6]
223
224 expect(email['from'][0]['address']).equal('test-admin@localhost')
225 expect(email['to'][0]['address']).equal('user_1@example.com')
226 expect(email['subject']).contains('Verify')
227
228 const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text'])
229 expect(verificationStringMatches).not.to.be.null
230
231 verificationString = verificationStringMatches[1]
232 expect(verificationString).to.not.be.undefined
233 expect(verificationString).to.have.length.above(2)
234
235 const userIdMatches = /userId=([0-9]+)/.exec(email['text'])
236 expect(userIdMatches).not.to.be.null
237
238 userId = parseInt(userIdMatches[1], 10)
239 })
240
241 it('Should not verify the email with an invalid verification string', async function () {
242 await verifyEmail(server.url, userId, verificationString + 'b', 403)
243 })
244
245 it('Should verify the email', async function () {
246 await verifyEmail(server.url, userId, verificationString)
247 })
248 })
249
210 after(async function () { 250 after(async function () {
211 killallServers([ server ]) 251 killallServers([ server ])
212 }) 252 })
diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts
index 4ce87fb91..21d75da3e 100644
--- a/server/tests/api/users/index.ts
+++ b/server/tests/api/users/index.ts
@@ -1,3 +1,4 @@
1import './user-subscriptions' 1import './user-subscriptions'
2import './users' 2import './users'
3import './users-verification'
3import './users-multiple-servers' 4import './users-multiple-servers'
diff --git a/server/tests/api/users/users-verification.ts b/server/tests/api/users/users-verification.ts
new file mode 100644
index 000000000..fa5f5e371
--- /dev/null
+++ b/server/tests/api/users/users-verification.ts
@@ -0,0 +1,133 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import {
6 registerUser, flushTests, getUserInformation, getMyUserInformation, killallServers,
7 userLogin, login, runServer, ServerInfo, verifyEmail, updateCustomSubConfig
8} from '../../utils'
9import { setAccessTokensToServers } from '../../utils/users/login'
10import { mockSmtpServer } from '../../utils/miscs/email'
11import { waitJobs } from '../../utils/server/jobs'
12
13const expect = chai.expect
14
15describe('Test users account verification', function () {
16 let server: ServerInfo
17 let userId: number
18 let verificationString: string
19 let expectedEmailsLength = 0
20 const user1 = {
21 username: 'user_1',
22 password: 'super password'
23 }
24 const user2 = {
25 username: 'user_2',
26 password: 'super password'
27 }
28 const emails: object[] = []
29
30 before(async function () {
31 this.timeout(30000)
32
33 await mockSmtpServer(emails)
34
35 await flushTests()
36
37 const overrideConfig = {
38 smtp: {
39 hostname: 'localhost'
40 }
41 }
42 server = await runServer(1, overrideConfig)
43
44 await setAccessTokensToServers([ server ])
45 })
46
47 it('Should register user and send verification email if verification required', async function () {
48 this.timeout(5000)
49 await updateCustomSubConfig(server.url, server.accessToken, {
50 signup: {
51 enabled: true,
52 requiresEmailVerification: true,
53 limit: 10
54 }
55 })
56
57 await registerUser(server.url, user1.username, user1.password)
58
59 await waitJobs(server)
60 expectedEmailsLength++
61 expect(emails).to.have.lengthOf(expectedEmailsLength)
62
63 const email = emails[expectedEmailsLength - 1]
64
65 const verificationStringMatches = /verificationString=([a-z0-9]+)/.exec(email['text'])
66 expect(verificationStringMatches).not.to.be.null
67
68 verificationString = verificationStringMatches[1]
69 expect(verificationString).to.have.length.above(2)
70
71 const userIdMatches = /userId=([0-9]+)/.exec(email['text'])
72 expect(userIdMatches).not.to.be.null
73
74 userId = parseInt(userIdMatches[1], 10)
75
76 const resUserInfo = await getUserInformation(server.url, server.accessToken, userId)
77 expect(resUserInfo.body.emailVerified).to.be.false
78 })
79
80 it('Should not allow login for user with unverified email', async function () {
81 const resLogin = await login(server.url, server.client, user1, 400)
82 expect(resLogin.body.error).to.contain('User email is not verified.')
83 })
84
85 it('Should verify the user via email and allow login', async function () {
86 await verifyEmail(server.url, userId, verificationString)
87 await login(server.url, server.client, user1)
88 const resUserVerified = await getUserInformation(server.url, server.accessToken, userId)
89 expect(resUserVerified.body.emailVerified).to.be.true
90 })
91
92 it('Should register user not requiring email verification if setting not enabled', async function () {
93 this.timeout(5000)
94 await updateCustomSubConfig(server.url, server.accessToken, {
95 signup: {
96 enabled: true,
97 requiresEmailVerification: false,
98 limit: 10
99 }
100 })
101
102 await registerUser(server.url, user2.username, user2.password)
103
104 await waitJobs(server)
105 expect(emails).to.have.lengthOf(expectedEmailsLength)
106
107 const accessToken = await userLogin(server, user2)
108
109 const resMyUserInfo = await getMyUserInformation(server.url, accessToken)
110 expect(resMyUserInfo.body.emailVerified).to.be.null
111 })
112
113 it('Should allow login for user with unverified email when setting later enabled', async function () {
114 await updateCustomSubConfig(server.url, server.accessToken, {
115 signup: {
116 enabled: true,
117 requiresEmailVerification: true,
118 limit: 10
119 }
120 })
121
122 await userLogin(server, user2)
123 })
124
125 after(async function () {
126 killallServers([ server ])
127
128 // Keep the logs if the test failed
129 if (this[ 'ok' ]) {
130 await flushTests()
131 }
132 })
133})
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts
index 04dcc8fd1..c0dd587ee 100644
--- a/server/tests/api/users/users.ts
+++ b/server/tests/api/users/users.ts
@@ -7,7 +7,7 @@ import {
7 createUser, flushTests, getBlacklistedVideosList, getMyUserInformation, getMyUserVideoQuotaUsed, getMyUserVideoRating, 7 createUser, flushTests, getBlacklistedVideosList, getMyUserInformation, getMyUserVideoQuotaUsed, getMyUserVideoRating,
8 getUserInformation, getUsersList, getUsersListPaginationAndSort, getVideosList, killallServers, login, makePutBodyRequest, rateVideo, 8 getUserInformation, getUsersList, getUsersListPaginationAndSort, getVideosList, killallServers, login, makePutBodyRequest, rateVideo,
9 registerUser, removeUser, removeVideo, runServer, ServerInfo, testImage, updateMyAvatar, updateMyUser, updateUser, uploadVideo, userLogin, 9 registerUser, removeUser, removeVideo, runServer, ServerInfo, testImage, updateMyAvatar, updateMyUser, updateUser, uploadVideo, userLogin,
10 deleteMe, blockUser, unblockUser 10 deleteMe, blockUser, unblockUser, updateCustomSubConfig
11} from '../../utils/index' 11} from '../../utils/index'
12import { follow } from '../../utils/server/follows' 12import { follow } from '../../utils/server/follows'
13import { setAccessTokensToServers } from '../../utils/users/login' 13import { setAccessTokensToServers } from '../../utils/users/login'
diff --git a/server/tests/utils/server/config.ts b/server/tests/utils/server/config.ts
index 799c31ae5..b85e02ab7 100644
--- a/server/tests/utils/server/config.ts
+++ b/server/tests/utils/server/config.ts
@@ -74,7 +74,8 @@ function updateCustomSubConfig (url: string, token: string, newConfig: any) {
74 }, 74 },
75 signup: { 75 signup: {
76 enabled: false, 76 enabled: false,
77 limit: 5 77 limit: 5,
78 requiresEmailVerification: false
78 }, 79 },
79 admin: { 80 admin: {
80 email: 'superadmin1@example.com' 81 email: 'superadmin1@example.com'
diff --git a/server/tests/utils/users/users.ts b/server/tests/utils/users/users.ts
index 5dba34b69..cd1b07701 100644
--- a/server/tests/utils/users/users.ts
+++ b/server/tests/utils/users/users.ts
@@ -246,6 +246,28 @@ function resetPassword (url: string, userId: number, verificationString: string,
246 }) 246 })
247} 247}
248 248
249function askSendVerifyEmail (url: string, email: string) {
250 const path = '/api/v1/users/ask-send-verify-email'
251
252 return makePostBodyRequest({
253 url,
254 path,
255 fields: { email },
256 statusCodeExpected: 204
257 })
258}
259
260function verifyEmail (url: string, userId: number, verificationString: string, statusCodeExpected = 204) {
261 const path = '/api/v1/users/' + userId + '/verify-email'
262
263 return makePostBodyRequest({
264 url,
265 path,
266 fields: { verificationString },
267 statusCodeExpected
268 })
269}
270
249// --------------------------------------------------------------------------- 271// ---------------------------------------------------------------------------
250 272
251export { 273export {
@@ -265,5 +287,7 @@ export {
265 unblockUser, 287 unblockUser,
266 askResetPassword, 288 askResetPassword,
267 resetPassword, 289 resetPassword,
268 updateMyAvatar 290 updateMyAvatar,
291 askSendVerifyEmail,
292 verifyEmail
269} 293}
diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts
index 2f5cebf7f..3afd36fcd 100644
--- a/shared/models/server/custom-config.model.ts
+++ b/shared/models/server/custom-config.model.ts
@@ -34,6 +34,7 @@ export interface CustomConfig {
34 signup: { 34 signup: {
35 enabled: boolean 35 enabled: boolean
36 limit: number 36 limit: number
37 requiresEmailVerification: boolean
37 } 38 }
38 39
39 admin: { 40 admin: {
diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts
index 9bbeb14d2..e0ff8c07d 100644
--- a/shared/models/server/server-config.model.ts
+++ b/shared/models/server/server-config.model.ts
@@ -16,7 +16,8 @@ export interface ServerConfig {
16 16
17 signup: { 17 signup: {
18 allowed: boolean, 18 allowed: boolean,
19 allowedForCurrentIP: boolean 19 allowedForCurrentIP: boolean,
20 requiresEmailVerification: boolean
20 } 21 }
21 22
22 transcoding: { 23 transcoding: {