add user account email verificiation (#977)
authorJosh Morel <morel.josh@hotmail.com>
Fri, 31 Aug 2018 07:18:19 +0000 (03:18 -0400)
committerChocobozzz <me@florianbigard.com>
Fri, 31 Aug 2018 07:18:19 +0000 (09:18 +0200)
* 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

42 files changed:
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
client/src/app/+verify-account/index.ts [new file with mode: 0644]
client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html [new file with mode: 0644]
client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.scss [new file with mode: 0644]
client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts [new file with mode: 0644]
client/src/app/+verify-account/verify-account-email/verify-account-email.component.html [new file with mode: 0644]
client/src/app/+verify-account/verify-account-email/verify-account-email.component.ts [new file with mode: 0644]
client/src/app/+verify-account/verify-account-routing.module.ts [new file with mode: 0644]
client/src/app/+verify-account/verify-account.module.ts [new file with mode: 0644]
client/src/app/app-routing.module.ts
client/src/app/core/server/server.service.ts
client/src/app/login/login.component.html
client/src/app/shared/users/user.service.ts
client/src/app/signup/signup.component.ts
config/default.yaml
config/production.yaml.example
config/test.yaml
server/controllers/api/config.ts
server/controllers/api/users/index.ts
server/helpers/audit-logger.ts
server/helpers/custom-validators/users.ts
server/initializers/checker.ts
server/initializers/constants.ts
server/initializers/installer.ts
server/initializers/migrations/0265-user-email-verified.ts [new file with mode: 0644]
server/lib/emailer.ts
server/lib/oauth-model.ts
server/lib/redis.ts
server/middlewares/validators/users.ts
server/models/account/user.ts
server/tests/api/check-params/config.ts
server/tests/api/check-params/users.ts
server/tests/api/server/config.ts
server/tests/api/server/email.ts
server/tests/api/users/index.ts
server/tests/api/users/users-verification.ts [new file with mode: 0644]
server/tests/api/users/users.ts
server/tests/utils/server/config.ts
server/tests/utils/users/users.ts
shared/models/server/custom-config.model.ts
shared/models/server/server-config.model.ts

index ca7890d848cce4749db0fcc15b168bef7a062c34..a0f0abd10e95ba8a3de45789fee6d6b4ff43fa18 100644 (file)
           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
index 248b0df50b1568f82e51743cddfadd29460d9aee..ce2ea8a6c15b911591405ee74a63954cc423610b 100644 (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,
diff --git a/client/src/app/+verify-account/index.ts b/client/src/app/+verify-account/index.ts
new file mode 100644 (file)
index 0000000..733f5ba
--- /dev/null
@@ -0,0 +1,2 @@
+export * from '@app/+verify-account/verify-account-routing.module'
+export * 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 (file)
index 0000000..2e41806
--- /dev/null
@@ -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>
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 (file)
index 0000000..efec6b7
--- /dev/null
@@ -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;
+}
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 (file)
index 0000000..995f42f
--- /dev/null
@@ -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)
+        }
+      )
+  }
+}
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 (file)
index 0000000..30ace5e
--- /dev/null
@@ -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>
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 (file)
index 0000000..26b3bf4
--- /dev/null
@@ -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)
+        }
+      )
+  }
+}
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 (file)
index 0000000..a038f03
--- /dev/null
@@ -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 {}
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 (file)
index 0000000..9092c6b
--- /dev/null
@@ -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 { }
index 30e615b3ed0a9158762cc32db7b3fd2ebd42609b..545d6aedab8e257358805f1a87ba87ae4572c188 100644 (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'
index a1ce120698534a5af1db528869ea93c252f2fa35..e7152efa0f8b17afcd352060aa6b715a321fec46 100644 (file)
@@ -40,7 +40,8 @@ export class ServerService {
     serverVersion: 'Unknown',
     signup: {
       allowed: false,
-      allowedForCurrentIP: false
+      allowedForCurrentIP: false,
+      requiresEmailVerification: false
     },
     transcoding: {
       enabledResolutions: []
index 3a6d61327c01125d73a9029b6ce3336f1a34eafa..619150adeab9ee68376ecda60c0ba8d39b7d8d5e 100644 (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">
index e6dc3dbf8e51d4a600fc8b3e816804877066f65c..249c589b71d771363722dceef2a989eaa2319e48 100644 (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))
+               )
+  }
 }
index 47f9bc6f4c5e778d8d6e02ffe0c21acc7f0ee428..16e4446785fbe742412ac11500db52ec3ffc0134 100644 (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()
       },
 
index 7799ea927926b549c77945ecd6b40eb8bdc77a31..ef63fbd281f1064f261988eef53df6b2dbc4686d 100644 (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: []
index 33a26dec18a6014af5585803d04ffdf34284152e..f7b15369867bf0fedbb4d1a1aa0fcc9d410e6e39 100644 (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: []
index 879b6bdd43a35869cab8625449f9f4546a31e981..6a8e47aace48d2e25a495ee4f2a069c9c7a45cb4 100644 (file)
@@ -29,6 +29,7 @@ cache:
 
 signup:
   enabled: true
+  requires_email_verification: false
 
 transcoding:
   enabled: true
index 25ddd1fa6d828bd701e3e2a4677098f0305a49e1..6edbe4820a32ee7f2e02a662671ea75ef66d447a 100644 (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
index 25d51ae5e744a5f3ec9ac3c62c899048d559e4c9..008c34ca480c42a11bf683375743025fc5834705 100644 (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()
 }
index db20df20fb53b0638f337a30916bf71c739e4a00..7db72b69c5226f537beda46a4e532120fc60edb6 100644 (file)
@@ -234,6 +234,7 @@ const customConfigKeysToKeep = [
   'cache-captions-size',
   'signup-enabled',
   'signup-limit',
+  'signup-requiresEmailVerification',
   'admin-email',
   'user-videoQuota',
   'transcoding-enabled',
index 8d6247e41d9461837db608f9d5299fd41272fb4f..90fc74a482c9bc45f3c1d4957f9f03f939de1098 100644 (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,
index 916e9067e4a79f34e26ca14f00e3d926755ab4f5..ee02ecf4867a04c43047acd2d26eba4bc1afb1ca 100644 (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',
index 2d9a2e670a2c8311e1c1837bfafc5a47e0a4e1a9..5d93c6b82458f1bec7bac7938b15ee5bd39ce831 100644 (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,
index d4aaec8fe54dcc197be997b5e6bcab021d8be4b7..818bb04a254ddbe5cc74e3191a9b17a1e7791265 100644 (file)
@@ -122,6 +122,7 @@ async function createOAuthAdminIfNotExist () {
     email,
     password,
     role,
+    verified: true,
     nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
     videoQuota: -1,
     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 (file)
index 0000000..59dfdad
--- /dev/null
@@ -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 }
index bf8e5b6c3bcd6ce7f5b3cc7b471f5e814ce19c00..9327792fb6f8a731e10e252f2325218eff24dfdd 100644 (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.')
index 09eaf75d1fc1e982eb80cc316803f3d40a49c749..2f8667e198bb05086fdf012b5caf439315bf50a1 100644 (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
 }
 
index 0b4b41e4ea1425629cd16fae1f63513c9aa6f1d4..e4e4356590d665cf479b896bd51a9f7805608317 100644 (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
   }
index 6c5e783e93c7560ae85fff4091a434cb1e4423bc..a595c39ec16abacc96dc44bf5c6fea2312908075 100644 (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
 }
 
 // ---------------------------------------------------------------------------
index bae683b128d03b9362ab619101a1bb40b5ebcb9f..89265774b6686674c5a98bcc685903927c1dcc67 100644 (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,
index ecfb76d47251fd0bfd2afbab4aa484415211659b..d807f910bde37a639f77832a984d155be4c01ee9 100644 (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'
index 8b2ed1b0484056fc63343cdef9b4690e3dfba786..95903c8a57d020a18e9b8f1a86833673cac5d7cc 100644 (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 ])
 
index ece4118a69a41c661e5bcdae56b9c68083dcd6c2..facd1688d33998090ca71b8a19dec141c7052c68 100644 (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'
index db937f2884f6dfe1fb40b330733eb53c15aea5f0..713a27143123a6e621ec2b704c40835196540e18 100644 (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 ])
   })
index 4ce87fb91097c5502a39f7db2c00a290f9ba358d..21d75da3ebc798d039f6291ab97f10c8ea288179 100644 (file)
@@ -1,3 +1,4 @@
 import './user-subscriptions'
 import './users'
+import './users-verification'
 import './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 (file)
index 0000000..fa5f5e3
--- /dev/null
@@ -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()
+    }
+  })
+})
index 04dcc8fd1a464d654768daf8ecc96cac41b31601..c0dd587eef72c7b84f7367817cc20254ef7b9610 100644 (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'
index 799c31ae58e2cacac0967acc8736a8739246f2d1..b85e02ab7712a41ad370a82aa9b8f2a247ed7219 100644 (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'
index 5dba34b6936bc2ec257926d11a7480c993e9881b..cd1b0770149f43ca8b88844031354748b1f2e741 100644 (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
 }
index 2f5cebf7f60a2607d7b00cf77bdf259b4961a309..3afd36fcdba282dc35e6af1fde29b563e22b0504 100644 (file)
@@ -34,6 +34,7 @@ export interface CustomConfig {
   signup: {
     enabled: boolean
     limit: number
+    requiresEmailVerification: boolean
   }
 
   admin: {
index 9bbeb14d281abdbe4815505f8ff80f447e225847..e0ff8c07def4755343fb4f56178d01b479e2b550 100644 (file)
@@ -16,7 +16,8 @@ export interface ServerConfig {
 
   signup: {
     allowed: boolean,
-    allowedForCurrentIP: boolean
+    allowedForCurrentIP: boolean,
+    requiresEmailVerification: boolean
   }
 
   transcoding: {