From d9eaee3939bf2e93e5d775d32bce77842201faba Mon Sep 17 00:00:00 2001 From: Josh Morel Date: Fri, 31 Aug 2018 03:18:19 -0400 Subject: [PATCH] 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 --- .../edit-custom-config.component.html | 5 + .../edit-custom-config.component.ts | 5 +- client/src/app/+verify-account/index.ts | 2 + ...rify-account-ask-send-email.component.html | 22 +++ ...rify-account-ask-send-email.component.scss | 12 ++ ...verify-account-ask-send-email.component.ts | 58 ++++++++ .../verify-account-email.component.html | 15 ++ .../verify-account-email.component.ts | 54 +++++++ .../verify-account-routing.module.ts | 42 ++++++ .../+verify-account/verify-account.module.ts | 27 ++++ client/src/app/app-routing.module.ts | 4 + client/src/app/core/server/server.service.ts | 3 +- client/src/app/login/login.component.html | 4 +- client/src/app/shared/users/user.service.ts | 23 +++ client/src/app/signup/signup.component.ts | 22 ++- config/default.yaml | 1 + config/production.yaml.example | 1 + config/test.yaml | 1 + server/controllers/api/config.ts | 16 ++- server/controllers/api/users/index.ts | 47 ++++++- server/helpers/audit-logger.ts | 1 + server/helpers/custom-validators/users.ts | 5 + server/initializers/checker.ts | 3 +- server/initializers/constants.ts | 6 +- server/initializers/installer.ts | 1 + .../migrations/0265-user-email-verified.ts | 24 ++++ server/lib/emailer.ts | 17 +++ server/lib/oauth-model.ts | 5 + server/lib/redis.ts | 18 ++- server/middlewares/validators/users.ts | 46 +++++- server/models/account/user.ts | 8 ++ server/tests/api/check-params/config.ts | 3 +- server/tests/api/check-params/users.ts | 22 +++ server/tests/api/server/config.ts | 5 +- server/tests/api/server/email.ts | 42 +++++- server/tests/api/users/index.ts | 1 + server/tests/api/users/users-verification.ts | 133 ++++++++++++++++++ server/tests/api/users/users.ts | 2 +- server/tests/utils/server/config.ts | 3 +- server/tests/utils/users/users.ts | 26 +++- shared/models/server/custom-config.model.ts | 1 + shared/models/server/server-config.model.ts | 3 +- 42 files changed, 715 insertions(+), 24 deletions(-) create mode 100644 client/src/app/+verify-account/index.ts create mode 100644 client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.html create mode 100644 client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.scss create mode 100644 client/src/app/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts create mode 100644 client/src/app/+verify-account/verify-account-email/verify-account-email.component.html create mode 100644 client/src/app/+verify-account/verify-account-email/verify-account-email.component.ts create mode 100644 client/src/app/+verify-account/verify-account-routing.module.ts create mode 100644 client/src/app/+verify-account/verify-account.module.ts create mode 100644 server/initializers/migrations/0265-user-email-verified.ts create mode 100644 server/tests/api/users/users-verification.ts 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 @@ i18n-labelText labelText="Signup enabled" > + +
+
+ Request email for account verification +
+ +
+
+ + +
+ {{ formErrors['verify-email-email'] }} +
+
+ +
+ +
This instance does not require email verification.
+
+
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 @@ +@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 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 @@ +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 index 000000000..30ace5e10 --- /dev/null +++ b/client/src/app/+verify-account/verify-account-email/verify-account-email.component.html @@ -0,0 +1,15 @@ +
+
+ Verify account email confirmation +
+ +
+ Your email has been verified and you may now login. Redirecting... +
+ +
+ An error occurred. + Request new verification email. +
+
+
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 @@ +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 index 000000000..a038f0336 --- /dev/null +++ b/client/src/app/+verify-account/verify-account-routing.module.ts @@ -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 index 000000000..9092c6b4f --- /dev/null +++ b/client/src/app/+verify-account/verify-account.module.ts @@ -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 { } 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 @@ -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' 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 { serverVersion: 'Unknown', signup: { allowed: false, - allowedForCurrentIP: false + allowedForCurrentIP: false, + requiresEmailVerification: false }, transcoding: { 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 @@ Login -
{{ error }}
+
{{ error }} + Request new verification email. +
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 { 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)) + ) + } } 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' 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() }, 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: 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: [] 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: 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: [] 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: signup: enabled: true + requires_email_verification: false transcoding: 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 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 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 { 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() } 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 = [ 'cache-captions-size', 'signup-enabled', 'signup-limit', + 'signup-requiresEmailVerification', 'admin-email', 'user-videoQuota', '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) { 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, 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 () { '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', 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') // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 260 +const LAST_MIGRATION_VERSION = 265 // --------------------------------------------------------------------------- @@ -204,6 +204,7 @@ const CONFIG = { SIGNUP: { get ENABLED () { return config.get('signup.enabled') }, get LIMIT () { return config.get('signup.limit') }, + get REQUIRES_EMAIL_VERIFICATION () { return config.get('signup.requires_email_verification') }, FILTERS: { CIDR: { get WHITELIST () { return config.get('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, 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 () { 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 index 000000000..59dfdad2b --- /dev/null +++ b/server/initializers/migrations/0265-user-email-verified.ts @@ -0,0 +1,24 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise { + { + 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 } 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 { 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.') 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' 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 } 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' 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 } 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 = [ } ] +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 } // --------------------------------------------------------------------------- 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 { isUserBlockedReasonValid, isUserBlockedValid, isUserNSFWPolicyValid, + isUserEmailVerifiedValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid, @@ -92,6 +93,12 @@ export class UserModel extends Model { @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 { id: this.id, username: this.username, email: this.email, + emailVerified: this.emailVerified, nsfwPolicy: this.nsfwPolicy, autoPlayVideo: this.autoPlayVideo, 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 () { }, signup: { enabled: false, - limit: 5 + limit: 5, + requiresEmailVerification: false }, admin: { 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 () { }) }) + 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 ]) 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) { 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' 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' 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 ]) }) 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 @@ 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 index 000000000..fa5f5e371 --- /dev/null +++ b/server/tests/api/users/users-verification.ts @@ -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() + } + }) +}) 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 { 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' 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) { }, signup: { enabled: false, - limit: 5 + limit: 5, + requiresEmailVerification: false }, admin: { 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, }) } +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 } 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 { signup: { enabled: boolean limit: number + requiresEmailVerification: boolean } 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 { signup: { allowed: boolean, - allowedForCurrentIP: boolean + allowedForCurrentIP: boolean, + requiresEmailVerification: boolean } transcoding: { -- 2.41.0