Add ability to reset our password

pull/267/head
Chocobozzz 2018-01-30 13:27:07 +01:00
parent 80d1057bfc
commit ecb4e35f4e
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
32 changed files with 741 additions and 67 deletions

2
.gitignore vendored
View File

@ -7,7 +7,7 @@
/test6/
/storage/
/config/production.yaml
/config/local*.json
/config/local*
/ffmpeg/
/*.sublime-project
/*.sublime-workspace

View File

@ -1,19 +1,20 @@
import { NgModule } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { ResetPasswordModule } from '@app/reset-password'
import { MetaModule, MetaLoader, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core'
import { MetaLoader, MetaModule, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core'
import { AccountModule } from './account'
import { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component'
import { AccountModule } from './account'
import { CoreModule } from './core'
import { LoginModule } from './login'
import { SignupModule } from './signup'
import { SharedModule } from './shared'
import { VideosModule } from './videos'
import { MenuComponent } from './menu'
import { HeaderComponent } from './header'
import { LoginModule } from './login'
import { MenuComponent } from './menu'
import { SharedModule } from './shared'
import { SignupModule } from './signup'
import { VideosModule } from './videos'
export function metaFactory (): MetaLoader {
return new MetaStaticLoader({
@ -46,6 +47,7 @@ export function metaFactory (): MetaLoader {
AccountModule,
CoreModule,
LoginModule,
ResetPasswordModule,
SignupModule,
SharedModule,
VideosModule,

View File

@ -19,10 +19,13 @@
<div class="form-group">
<label for="password">Password</label>
<input
type="password" name="password" id="password" placeholder="Password" required
formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
>
<div>
<input
type="password" name="password" id="password" placeholder="Password" required
formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
>
<div class="forgot-password-button" (click)="openForgotPasswordModal()">I forgot my password</div>
</div>
<div *ngIf="formErrors.password" class="form-error">
{{ formErrors.password }}
</div>
@ -31,3 +34,36 @@
<input type="submit" value="Login" [disabled]="!form.valid">
</form>
</div>
<div bsModal #forgotPasswordModal="bs-modal" (onShown)="onForgotPasswordModalShown()" class="modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<span class="close" aria-hidden="true" (click)="hideForgotPasswordModal()"></span>
<h4 class="modal-title">Forgot your password</h4>
</div>
<div class="modal-body">
<div class="form-group">
<label for="forgot-password-email">Email</label>
<input
type="email" id="forgot-password-email" placeholder="Email address" required
[(ngModel)]="forgotPasswordEmail" #forgotPasswordEmailInput
>
</div>
<div class="form-group inputs">
<span class="action-button action-button-cancel" (click)="hideForgotPasswordModal()">
Cancel
</span>
<input
type="submit" value="Send me an email to reset my password" class="action-button-submit"
(click)="askResetPassword()" [disabled]="!forgotPasswordEmailInput.validity.valid"
>
</div>
</div>
</div>
</div>
</div>

View File

@ -10,3 +10,13 @@ input[type=submit] {
@include peertube-button;
@include orange-button;
}
input[type=password] {
display: inline-block;
margin-right: 5px;
}
.forgot-password-button {
display: inline-block;
cursor: pointer;
}

View File

@ -1,7 +1,9 @@
import { Component, OnInit } from '@angular/core'
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
import { FormBuilder, FormGroup, Validators } from '@angular/forms'
import { Router } from '@angular/router'
import { UserService } from '@app/shared'
import { NotificationsService } from 'angular2-notifications'
import { ModalDirective } from 'ngx-bootstrap/modal'
import { AuthService } from '../core'
import { FormReactive } from '../shared'
@ -12,6 +14,9 @@ import { FormReactive } from '../shared'
})
export class LoginComponent extends FormReactive implements OnInit {
@ViewChild('forgotPasswordModal') forgotPasswordModal: ModalDirective
@ViewChild('forgotPasswordEmailInput') forgotPasswordEmailInput: ElementRef
error: string = null
form: FormGroup
@ -27,9 +32,12 @@ export class LoginComponent extends FormReactive implements OnInit {
'required': 'Password is required.'
}
}
forgotPasswordEmail = ''
constructor (
private authService: AuthService,
private userService: UserService,
private notificationsService: NotificationsService,
private formBuilder: FormBuilder,
private router: Router
) {
@ -60,4 +68,29 @@ export class LoginComponent extends FormReactive implements OnInit {
err => this.error = err.message
)
}
askResetPassword () {
this.userService.askResetPassword(this.forgotPasswordEmail)
.subscribe(
res => {
const message = `An email with the reset password instructions will be sent to ${this.forgotPasswordEmail}.`
this.notificationsService.success('Success', message)
this.hideForgotPasswordModal()
},
err => this.notificationsService.error('Error', err.message)
)
}
onForgotPasswordModalShown () {
this.forgotPasswordEmailInput.nativeElement.focus()
}
openForgotPasswordModal () {
this.forgotPasswordModal.show()
}
hideForgotPasswordModal () {
this.forgotPasswordModal.hide()
}
}

View File

@ -0,0 +1,3 @@
export * from './reset-password-routing.module'
export * from './reset-password.component'
export * from './reset-password.module'

View File

@ -0,0 +1,25 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { MetaGuard } from '@ngx-meta/core'
import { ResetPasswordComponent } from './reset-password.component'
const resetPasswordRoutes: Routes = [
{
path: 'reset-password',
component: ResetPasswordComponent,
canActivate: [ MetaGuard ],
data: {
meta: {
title: 'Reset password'
}
}
}
]
@NgModule({
imports: [ RouterModule.forChild(resetPasswordRoutes) ],
exports: [ RouterModule ]
})
export class ResetPasswordRoutingModule {}

View File

@ -0,0 +1,33 @@
<div class="margin-content">
<div class="title-page title-page-single">
Reset my password
</div>
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
<form role="form" (ngSubmit)="resetPassword()" [formGroup]="form">
<div class="form-group">
<label for="password">Password</label>
<input
type="password" name="password" id="password" placeholder="Password" required
formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }"
>
<div *ngIf="formErrors.password" class="form-error">
{{ formErrors.password }}
</div>
</div>
<div class="form-group">
<label for="password-confirm">Confirm password</label>
<input
type="password" name="password-confirm" id="password-confirm" placeholder="Confirmed password" required
formControlName="password-confirm" [ngClass]="{ 'input-error': formErrors['password-confirm'] }"
>
<div *ngIf="formErrors['password-confirm']" class="form-error">
{{ formErrors['password-confirm'] }}
</div>
</div>
<input type="submit" value="Reset my password" [disabled]="!form.valid && isConfirmedPasswordValid()">
</form>
</div>

View File

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

View File

@ -0,0 +1,79 @@
import { Component, OnInit } from '@angular/core'
import { FormBuilder, FormGroup, Validators } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
import { USER_PASSWORD, UserService } from '@app/shared'
import { NotificationsService } from 'angular2-notifications'
import { AuthService } from '../core'
import { FormReactive } from '../shared'
@Component({
selector: 'my-login',
templateUrl: './reset-password.component.html',
styleUrls: [ './reset-password.component.scss' ]
})
export class ResetPasswordComponent extends FormReactive implements OnInit {
form: FormGroup
formErrors = {
'password': '',
'password-confirm': ''
}
validationMessages = {
'password': USER_PASSWORD.MESSAGES,
'password-confirm': {
'required': 'Confirmation of the password is required.'
}
}
private userId: number
private verificationString: string
constructor (
private authService: AuthService,
private userService: UserService,
private notificationsService: NotificationsService,
private formBuilder: FormBuilder,
private router: Router,
private route: ActivatedRoute
) {
super()
}
buildForm () {
this.form = this.formBuilder.group({
password: [ '', USER_PASSWORD.VALIDATORS ],
'password-confirm': [ '', Validators.required ]
})
this.form.valueChanges.subscribe(data => this.onValueChanged(data))
}
ngOnInit () {
this.buildForm()
this.userId = this.route.snapshot.queryParams['userId']
this.verificationString = this.route.snapshot.queryParams['verificationString']
if (!this.userId || !this.verificationString) {
this.notificationsService.error('Error', 'Unable to find user id or verification string.')
this.router.navigate([ '/' ])
}
}
resetPassword () {
this.userService.resetPassword(this.userId, this.verificationString, this.form.value.password)
.subscribe(
() => {
this.notificationsService.success('Success', 'Your password has been successfully reset!')
this.router.navigate([ '/login' ])
},
err => this.notificationsService.error('Error', err.message)
)
}
isConfirmedPasswordValid () {
const values = this.form.value
return values.password === values['password-confirm']
}
}

View File

@ -0,0 +1,24 @@
import { NgModule } from '@angular/core'
import { ResetPasswordRoutingModule } from './reset-password-routing.module'
import { ResetPasswordComponent } from './reset-password.component'
import { SharedModule } from '../shared'
@NgModule({
imports: [
ResetPasswordRoutingModule,
SharedModule
],
declarations: [
ResetPasswordComponent
],
exports: [
ResetPasswordComponent
],
providers: [
]
})
export class ResetPasswordModule { }

View File

@ -5,7 +5,6 @@ import 'rxjs/add/operator/map'
import { UserCreate, UserUpdateMe } from '../../../../../shared'
import { environment } from '../../../environments/environment'
import { RestExtractor } from '../rest'
import { User } from './user.model'
@Injectable()
export class UserService {
@ -54,4 +53,24 @@ export class UserService {
return this.authHttp.get(url)
.catch(res => this.restExtractor.handleError(res))
}
askResetPassword (email: string) {
const url = UserService.BASE_USERS_URL + '/ask-reset-password'
return this.authHttp.post(url, { email })
.map(this.restExtractor.extractDataBool)
.catch(res => this.restExtractor.handleError(res))
}
resetPassword (userId: number, verificationString: string, password: string) {
const url = `${UserService.BASE_USERS_URL}/${userId}/reset-password`
const body = {
verificationString,
password
}
return this.authHttp.post(url, body)
.map(this.restExtractor.extractDataBool)
.catch(res => this.restExtractor.handleError(res))
}
}

View File

@ -19,26 +19,30 @@
*/
/** IE9, IE10 and IE11 requires all of the following polyfills. **/
// import 'core-js/es6/symbol';
// import 'core-js/es6/object';
// import 'core-js/es6/function';
// import 'core-js/es6/parse-int';
// import 'core-js/es6/parse-float';
// import 'core-js/es6/number';
// import 'core-js/es6/math';
// import 'core-js/es6/string';
// import 'core-js/es6/date';
// import 'core-js/es6/array';
// import 'core-js/es6/regexp';
// import 'core-js/es6/map';
// import 'core-js/es6/weak-map';
// import 'core-js/es6/set';
// For Google Bot
import 'core-js/es6/symbol';
import 'core-js/es6/object';
import 'core-js/es6/function';
import 'core-js/es6/parse-int';
import 'core-js/es6/parse-float';
import 'core-js/es6/number';
import 'core-js/es6/math';
import 'core-js/es6/string';
import 'core-js/es6/date';
import 'core-js/es6/array';
import 'core-js/es6/regexp';
import 'core-js/es6/map';
import 'core-js/es6/weak-map';
import 'core-js/es6/set';
/** IE10 and IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/** IE10 and IE11 requires the following for the Reflect API. */
// import 'core-js/es6/reflect';
// For Google Bot
import 'core-js/es6/reflect';
/** Evergreen browsers require these. **/

View File

@ -19,7 +19,7 @@ $FontPathSourceSansPro: '../../node_modules/npm-font-source-sans-pro/fonts';
}
body {
font-family: 'Source Sans Pro';
font-family: 'Source Sans Pro', sans-serif;
font-weight: $font-regular;
color: #000;
}

View File

@ -19,6 +19,15 @@ redis:
port: 6379
auth: null
smtp:
hostname: null
port: 465
username: null
password: null
tls: true
ca_file: null # Used for self signed certificates
from_address: 'admin@example.com'
# From the project root directory
storage:
avatars: 'storage/avatars/'
@ -37,7 +46,7 @@ cache:
size: 1 # Max number of previews you want to cache
admin:
email: 'admin@example.com'
email: 'admin@example.com' # Your personal email as administrator
signup:
enabled: false

View File

@ -20,6 +20,15 @@ redis:
port: 6379
auth: null
smtp:
hostname: null
port: 465
username: null
password: null
tls: true
ca_file: null # Used for self signed certificates
from_address: 'admin@example.com'
# From the project root directory
storage:
avatars: '/var/www/peertube/storage/avatars/'

View File

@ -76,11 +76,13 @@
"mkdirp": "^0.5.1",
"morgan": "^1.5.3",
"multer": "^1.1.0",
"nodemailer": "^4.4.2",
"parse-torrent": "^5.8.0",
"password-generator": "^2.0.2",
"pem": "^1.12.3",
"pg": "^6.4.2",
"pg-hstore": "^2.3.2",
"redis": "^2.8.0",
"reflect-metadata": "^0.1.10",
"request": "^2.81.0",
"rimraf": "^2.5.4",
@ -112,7 +114,9 @@
"@types/morgan": "^1.7.32",
"@types/multer": "^1.3.3",
"@types/node": "^9.3.0",
"@types/nodemailer": "^4.3.1",
"@types/pem": "^1.9.3",
"@types/redis": "^2.8.5",
"@types/request": "^2.0.3",
"@types/sequelize": "^4.0.55",
"@types/sharp": "^0.17.6",

View File

@ -3,12 +3,11 @@
printf "############# PeerTube help #############\n\n"
printf "npm run ...\n"
printf " build -> Build the application for production (alias of build:client:prod)\n"
printf " build:server:prod -> Build the server for production\n"
printf " build:client:prod -> Build the client for production\n"
printf " clean -> Clean the application\n"
printf " build:server -> Build the server for production\n"
printf " build:client -> Build the client for production\n"
printf " clean:client -> Clean the client build files (dist directory)\n"
printf " clean:server:test -> Clean certificates, logs, uploads and database of the test instances\n"
printf " watch:client -> Watch the client files\n"
printf " clean:server:test -> Clean logs, uploads, database... of the test instances\n"
printf " watch:client -> Watch and compile on the fly the client files\n"
printf " danger:clean:dev -> /!\ Clean certificates, logs, uploads, thumbnails, torrents and database specified in the development environment\n"
printf " danger:clean:prod -> /!\ Clean certificates, logs, uploads, thumbnails, torrents and database specified by the production environment\n"
printf " danger:clean:modules -> /!\ Clean node and typescript modules\n"
@ -16,8 +15,7 @@ printf " play -> Run 3 fresh nodes so that you can test
printf " reset-password -- -u [user] -> Reset the password of user [user]\n"
printf " dev -> Watch, run the livereload and run the server so that you can develop the application\n"
printf " start -> Run the server\n"
printf " check -> Check the server (according to NODE_ENV)\n"
printf " upgrade -- [branch] -> Upgrade the application according to the [branch] parameter\n"
printf " update-host -> Upgrade scheme/host in torrent files according to the webserver configuration (config/ folder)\n"
printf " client-report -> Open a report of the client dependencies module\n"
printf " test -> Run the tests\n"
printf " help -> Print this help\n"

View File

@ -53,9 +53,11 @@ migrate()
// ----------- PeerTube modules -----------
import { installApplication } from './server/initializers'
import { Emailer } from './server/lib/emailer'
import { JobQueue } from './server/lib/job-queue'
import { VideosPreviewCache } from './server/lib/cache'
import { apiRouter, clientsRouter, staticRouter, servicesRouter, webfingerRouter, activityPubRouter } from './server/controllers'
import { Redis } from './server/lib/redis'
import { BadActorFollowScheduler } from './server/lib/schedulers/bad-actor-follow-scheduler'
import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler'
@ -169,10 +171,20 @@ function onDatabaseInitDone () {
.then(() => {
// ----------- Make the server listening -----------
server.listen(port, () => {
// Emailer initialization and then job queue initialization
Emailer.Instance.init()
Emailer.Instance.checkConnectionOrDie()
.then(() => JobQueue.Instance.init())
// Caches initializations
VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE)
// Enable Schedulers
BadActorFollowScheduler.Instance.enable()
RemoveOldJobsScheduler.Instance.enable()
JobQueue.Instance.init()
// Redis initialization
Redis.Instance.init()
logger.info('Server listening on port %d', port)
logger.info('Web server: %s', CONFIG.WEBSERVER.URL)

View File

@ -6,17 +6,23 @@ import { UserCreate, UserRight, UserRole, UserUpdate, UserUpdateMe, UserVideoRat
import { unlinkPromise } from '../../helpers/core-utils'
import { retryTransactionWrapper } from '../../helpers/database-utils'
import { logger } from '../../helpers/logger'
import { createReqFiles, getFormattedObjects } from '../../helpers/utils'
import { createReqFiles, generateRandomString, getFormattedObjects } from '../../helpers/utils'
import { AVATAR_MIMETYPE_EXT, AVATARS_SIZE, CONFIG, sequelizeTypescript } from '../../initializers'
import { updateActorAvatarInstance } from '../../lib/activitypub'
import { sendUpdateUser } from '../../lib/activitypub/send'
import { Emailer } from '../../lib/emailer'
import { EmailPayload } from '../../lib/job-queue/handlers/email'
import { Redis } from '../../lib/redis'
import { createUserAccountAndChannel } from '../../lib/user'
import {
asyncMiddleware, authenticate, ensureUserHasRight, ensureUserRegistrationAllowed, paginationValidator, setDefaultSort,
setDefaultPagination, token, usersAddValidator, usersGetValidator, usersRegisterValidator, usersRemoveValidator, usersSortValidator,
usersUpdateMeValidator, usersUpdateValidator, usersVideoRatingValidator
} from '../../middlewares'
import { usersUpdateMyAvatarValidator, videosSortValidator } from '../../middlewares/validators'
import {
usersAskResetPasswordValidator, usersResetPasswordValidator, usersUpdateMyAvatarValidator,
videosSortValidator
} from '../../middlewares/validators'
import { AccountVideoRateModel } from '../../models/account/account-video-rate'
import { UserModel } from '../../models/account/user'
import { OAuthTokenModel } from '../../models/oauth/oauth-token'
@ -106,6 +112,16 @@ usersRouter.delete('/:id',
asyncMiddleware(removeUser)
)
usersRouter.post('/ask-reset-password',
asyncMiddleware(usersAskResetPasswordValidator),
asyncMiddleware(askResetUserPassword)
)
usersRouter.post('/:id/reset-password',
asyncMiddleware(usersResetPasswordValidator),
asyncMiddleware(resetUserPassword)
)
usersRouter.post('/token', token, success)
// TODO: Once https://github.com/oauthjs/node-oauth2-server/pull/289 is merged, implement revoke token route
@ -307,6 +323,25 @@ async function updateUser (req: express.Request, res: express.Response, next: ex
return res.sendStatus(204)
}
async function askResetUserPassword (req: express.Request, res: express.Response, next: express.NextFunction) {
const user = res.locals.user as UserModel
const verificationString = await Redis.Instance.setResetPasswordVerificationString(user.id)
const url = CONFIG.WEBSERVER.URL + '/reset-password?userId=' + user.id + '&verificationString=' + verificationString
await Emailer.Instance.addForgetPasswordEmailJob(user.email, url)
return res.status(204).end()
}
async function resetUserPassword (req: express.Request, res: express.Response, next: express.NextFunction) {
const user = res.locals.user as UserModel
user.password = req.body.password
await user.save()
return res.status(204).end()
}
function success (req: express.Request, res: express.Response, next: express.NextFunction) {
res.end()
}

View File

@ -26,6 +26,7 @@ const loggerFormat = winston.format.printf((info) => {
if (additionalInfos === '{}') additionalInfos = ''
else additionalInfos = ' ' + additionalInfos
if (info.message.stack !== undefined) info.message = info.message.stack
return `[${info.label}] ${info.timestamp} ${info.level}: ${info.message}${additionalInfos}`
})

View File

@ -22,7 +22,8 @@ function checkMissedConfig () {
'webserver.https', 'webserver.hostname', 'webserver.port',
'database.hostname', 'database.port', 'database.suffix', 'database.username', 'database.password',
'storage.videos', 'storage.logs', 'storage.thumbnails', 'storage.previews', 'storage.torrents', 'storage.cache', 'log.level',
'cache.previews.size', 'admin.email', 'signup.enabled', 'signup.limit', 'transcoding.enabled', 'transcoding.threads', 'user.video_quota'
'cache.previews.size', 'admin.email', 'signup.enabled', 'signup.limit', 'transcoding.enabled', 'transcoding.threads',
'user.video_quota', 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address'
]
const miss: string[] = []

View File

@ -65,13 +65,15 @@ const JOB_ATTEMPTS: { [ id in JobType ]: number } = {
'activitypub-http-broadcast': 5,
'activitypub-http-unicast': 5,
'activitypub-http-fetcher': 5,
'video-file': 1
'video-file': 1,
'email': 5
}
const JOB_CONCURRENCY: { [ id in JobType ]: number } = {
'activitypub-http-broadcast': 1,
'activitypub-http-unicast': 5,
'activitypub-http-fetcher': 1,
'video-file': 1
'video-file': 1,
'email': 5
}
// 2 days
const JOB_COMPLETED_LIFETIME = 60000 * 60 * 24 * 2
@ -95,9 +97,18 @@ const CONFIG = {
},
REDIS: {
HOSTNAME: config.get<string>('redis.hostname'),
PORT: config.get<string>('redis.port'),
PORT: config.get<number>('redis.port'),
AUTH: config.get<string>('redis.auth')
},
SMTP: {
HOSTNAME: config.get<string>('smtp.hostname'),
PORT: config.get<number>('smtp.port'),
USERNAME: config.get<string>('smtp.username'),
PASSWORD: config.get<string>('smtp.password'),
TLS: config.get<boolean>('smtp.tls'),
CA_FILE: config.get<string>('smtp.ca_file'),
FROM_ADDRESS: config.get<string>('smtp.from_address')
},
STORAGE: {
AVATARS_DIR: buildPath(config.get<string>('storage.avatars')),
LOG_DIR: buildPath(config.get<string>('storage.logs')),
@ -311,6 +322,8 @@ const PRIVATE_RSA_KEY_SIZE = 2048
// Password encryption
const BCRYPT_SALT_SIZE = 10
const USER_PASSWORD_RESET_LIFETIME = 60000 * 5 // 5 minutes
// ---------------------------------------------------------------------------
// Express static paths (router)
@ -408,6 +421,7 @@ export {
VIDEO_LICENCES,
VIDEO_RATE_TYPES,
VIDEO_MIMETYPE_EXT,
USER_PASSWORD_RESET_LIFETIME,
AVATAR_MIMETYPE_EXT,
SCHEDULER_INTERVAL,
JOB_COMPLETED_LIFETIME

106
server/lib/emailer.ts Normal file
View File

@ -0,0 +1,106 @@
import { createTransport, Transporter } from 'nodemailer'
import { isTestInstance } from '../helpers/core-utils'
import { logger } from '../helpers/logger'
import { CONFIG } from '../initializers'
import { JobQueue } from './job-queue'
import { EmailPayload } from './job-queue/handlers/email'
import { readFileSync } from 'fs'
class Emailer {
private static instance: Emailer
private initialized = false
private transporter: Transporter
private constructor () {}
init () {
// Already initialized
if (this.initialized === true) return
this.initialized = true
if (CONFIG.SMTP.HOSTNAME && CONFIG.SMTP.PORT) {
logger.info('Using %s:%s as SMTP server.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT)
let tls
if (CONFIG.SMTP.CA_FILE) {
tls = {
ca: [ readFileSync(CONFIG.SMTP.CA_FILE) ]
}
}
this.transporter = createTransport({
host: CONFIG.SMTP.HOSTNAME,
port: CONFIG.SMTP.PORT,
secure: CONFIG.SMTP.TLS,
tls,
auth: {
user: CONFIG.SMTP.USERNAME,
pass: CONFIG.SMTP.PASSWORD
}
})
} else {
if (!isTestInstance()) {
logger.error('Cannot use SMTP server because of lack of configuration. PeerTube will not be able to send mails!')
}
}
}
async checkConnectionOrDie () {
if (!this.transporter) return
try {
const success = await this.transporter.verify()
if (success !== true) this.dieOnConnectionFailure()
logger.info('Successfully connected to SMTP server.')
} catch (err) {
this.dieOnConnectionFailure(err)
}
}
addForgetPasswordEmailJob (to: string, resetPasswordUrl: string) {
const text = `Hi dear user,\n\n` +
`It seems you forgot your password on ${CONFIG.WEBSERVER.HOST}! ` +
`Please follow this link to reset it: ${resetPasswordUrl}.\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: 'Reset your PeerTube password',
text
}
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
}
sendMail (to: string[], subject: string, text: string) {
if (!this.transporter) {
throw new Error('Cannot send mail because SMTP is not configured.')
}
return this.transporter.sendMail({
from: CONFIG.SMTP.FROM_ADDRESS,
to: to.join(','),
subject,
text
})
}
private dieOnConnectionFailure (err?: Error) {
logger.error('Failed to connect to SMTP %s:%d.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT, err)
process.exit(-1)
}
static get Instance () {
return this.instance || (this.instance = new this())
}
}
// ---------------------------------------------------------------------------
export {
Emailer
}

View File

@ -0,0 +1,22 @@
import * as kue from 'kue'
import { logger } from '../../../helpers/logger'
import { Emailer } from '../../emailer'
export type EmailPayload = {
to: string[]
subject: string
text: string
}
async function processEmail (job: kue.Job) {
const payload = job.data as EmailPayload
logger.info('Processing email in job %d.', job.id)
return Emailer.Instance.sendMail(payload.to, payload.subject, payload.text)
}
// ---------------------------------------------------------------------------
export {
processEmail
}

View File

@ -5,19 +5,22 @@ import { CONFIG, JOB_ATTEMPTS, JOB_COMPLETED_LIFETIME, JOB_CONCURRENCY } from '.
import { ActivitypubHttpBroadcastPayload, processActivityPubHttpBroadcast } from './handlers/activitypub-http-broadcast'
import { ActivitypubHttpFetcherPayload, processActivityPubHttpFetcher } from './handlers/activitypub-http-fetcher'
import { ActivitypubHttpUnicastPayload, processActivityPubHttpUnicast } from './handlers/activitypub-http-unicast'
import { EmailPayload, processEmail } from './handlers/email'
import { processVideoFile, VideoFilePayload } from './handlers/video-file'
type CreateJobArgument =
{ type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
{ type: 'activitypub-http-unicast', payload: ActivitypubHttpUnicastPayload } |
{ type: 'activitypub-http-fetcher', payload: ActivitypubHttpFetcherPayload } |
{ type: 'video-file', payload: VideoFilePayload }
{ type: 'video-file', payload: VideoFilePayload } |
{ type: 'email', payload: EmailPayload }
const handlers: { [ id in JobType ]: (job: kue.Job) => Promise<any>} = {
'activitypub-http-broadcast': processActivityPubHttpBroadcast,
'activitypub-http-unicast': processActivityPubHttpUnicast,
'activitypub-http-fetcher': processActivityPubHttpFetcher,
'video-file': processVideoFile
'video-file': processVideoFile,
'email': processEmail
}
class JobQueue {
@ -43,6 +46,8 @@ class JobQueue {
}
})
this.jobQueue.setMaxListeners(15)
this.jobQueue.on('error', err => {
logger.error('Error in job queue.', err)
process.exit(-1)

84
server/lib/redis.ts Normal file
View File

@ -0,0 +1,84 @@
import { createClient, RedisClient } from 'redis'
import { logger } from '../helpers/logger'
import { generateRandomString } from '../helpers/utils'
import { CONFIG, USER_PASSWORD_RESET_LIFETIME } from '../initializers'
class Redis {
private static instance: Redis
private initialized = false
private client: RedisClient
private prefix: string
private constructor () {}
init () {
// Already initialized
if (this.initialized === true) return
this.initialized = true
this.client = createClient({
host: CONFIG.REDIS.HOSTNAME,
port: CONFIG.REDIS.PORT
})
this.client.on('error', err => {
logger.error('Error in Redis client.', err)
process.exit(-1)
})
if (CONFIG.REDIS.AUTH) {
this.client.auth(CONFIG.REDIS.AUTH)
}
this.prefix = 'redis-' + CONFIG.WEBSERVER.HOST + '-'
}
async setResetPasswordVerificationString (userId: number) {
const generatedString = await generateRandomString(32)
await this.setValue(this.generateResetPasswordKey(userId), generatedString, USER_PASSWORD_RESET_LIFETIME)
return generatedString
}
async getResetPasswordLink (userId: number) {
return this.getValue(this.generateResetPasswordKey(userId))
}
private getValue (key: string) {
return new Promise<string>((res, rej) => {
this.client.get(this.prefix + key, (err, value) => {
if (err) return rej(err)
return res(value)
})
})
}
private setValue (key: string, value: string, expirationMilliseconds: number) {
return new Promise<void>((res, rej) => {
this.client.set(this.prefix + key, value, 'PX', expirationMilliseconds, (err, ok) => {
if (err) return rej(err)
if (ok !== 'OK') return rej(new Error('Redis result is not OK.'))
return res()
})
})
}
private generateResetPasswordKey (userId: number) {
return 'reset-password-' + userId
}
static get Instance () {
return this.instance || (this.instance = new this())
}
}
// ---------------------------------------------------------------------------
export {
Redis
}

View File

@ -1,18 +1,25 @@
import * as Bluebird from 'bluebird'
import * as express from 'express'
import 'express-validator'
import { body, param } from 'express-validator/check'
import { omit } from 'lodash'
import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
import {
isAvatarFile, isUserAutoPlayVideoValid, isUserDisplayNSFWValid, isUserPasswordValid, isUserRoleValid, isUserUsernameValid,
isAvatarFile,
isUserAutoPlayVideoValid,
isUserDisplayNSFWValid,
isUserPasswordValid,
isUserRoleValid,
isUserUsernameValid,
isUserVideoQuotaValid
} from '../../helpers/custom-validators/users'
import { isVideoExist } from '../../helpers/custom-validators/videos'
import { logger } from '../../helpers/logger'
import { isSignupAllowed } from '../../helpers/utils'
import { CONSTRAINTS_FIELDS } from '../../initializers'
import { Redis } from '../../lib/redis'
import { UserModel } from '../../models/account/user'
import { areValidationErrors } from './utils'
import { omit } from 'lodash'
const usersAddValidator = [
body('username').custom(isUserUsernameValid).withMessage('Should have a valid username (lowercase alphanumeric characters)'),
@ -167,6 +174,49 @@ const ensureUserRegistrationAllowed = [
}
]
const usersAskResetPasswordValidator = [
body('email').isEmail().not().isEmpty().withMessage('Should have a valid email'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking usersAskResetPassword 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 reset password).', req.body.email)
// Do not leak our emails
return res.status(204).end()
}
return next()
}
]
const usersResetPasswordValidator = [
param('id').isInt().not().isEmpty().withMessage('Should have a valid id'),
body('verificationString').not().isEmpty().withMessage('Should have a valid verification string'),
body('password').custom(isUserPasswordValid).withMessage('Should have a valid password'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking usersResetPassword 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.getResetPasswordLink(user.id)
if (redisVerificationString !== req.body.verificationString) {
return res
.status(403)
.send({ error: 'Invalid verification string.' })
.end
}
return next()
}
]
// ---------------------------------------------------------------------------
export {
@ -178,24 +228,19 @@ export {
usersVideoRatingValidator,
ensureUserRegistrationAllowed,
usersGetValidator,
usersUpdateMyAvatarValidator
usersUpdateMyAvatarValidator,
usersAskResetPasswordValidator,
usersResetPasswordValidator
}
// ---------------------------------------------------------------------------
async function checkUserIdExist (id: number, res: express.Response) {
const user = await UserModel.loadById(id)
function checkUserIdExist (id: number, res: express.Response) {
return checkUserExist(() => UserModel.loadById(id), res)
}
if (!user) {
res.status(404)
.send({ error: 'User not found' })
.end()
return false
}
res.locals.user = user
return true
function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {
return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse)
}
async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) {
@ -210,3 +255,21 @@ async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email:
return true
}
async function checkUserExist (finder: () => Bluebird<UserModel>, res: express.Response, abortResponse = true) {
const user = await finder()
if (!user) {
if (abortResponse === true) {
res.status(404)
.send({ error: 'User not found' })
.end()
}
return false
}
res.locals.user = user
return true
}

View File

@ -161,6 +161,16 @@ export class UserModel extends Model<UserModel> {
return UserModel.scope('withVideoChannel').findOne(query)
}
static loadByEmail (email: string) {
const query = {
where: {
email
}
}
return UserModel.findOne(query)
}
static loadByUsernameOrEmail (username: string, email?: string) {
if (!email) email = username

View File

@ -3,7 +3,8 @@ export type JobState = 'active' | 'complete' | 'failed' | 'inactive' | 'delayed'
export type JobType = 'activitypub-http-unicast' |
'activitypub-http-broadcast' |
'activitypub-http-fetcher' |
'video-file'
'video-file' |
'email'
export interface Job {
id: number

View File

@ -19,6 +19,8 @@
},
"exclude": [
"node_modules",
"dist",
"storage",
"client",
"test1",
"test2",

View File

@ -134,6 +134,12 @@
version "6.0.41"
resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.41.tgz#578cf53aaec65887bcaf16792f8722932e8ff8ea"
"@types/nodemailer@^4.3.1":
version "4.3.1"
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-4.3.1.tgz#e3985c1b7c7bbbb2a886108b89f1c7ce9a690654"
dependencies:
"@types/node" "*"
"@types/parse-torrent-file@*":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@types/parse-torrent-file/-/parse-torrent-file-4.0.1.tgz#056a6c18f3fac0cd7c6c74540f00496a3225976b"
@ -152,7 +158,7 @@
version "1.9.3"
resolved "https://registry.yarnpkg.com/@types/pem/-/pem-1.9.3.tgz#0c864c8b79e43fef6367db895f60fd1edd10e86c"
"@types/redis@*":
"@types/redis@*", "@types/redis@^2.8.5":
version "2.8.5"
resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.5.tgz#c4a31a63e95434202eb84908290528ad8510b149"
dependencies:
@ -4274,6 +4280,10 @@ node-sass@^4.0.0:
stdout-stream "^1.4.0"
"true-case-path" "^1.0.2"
nodemailer@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.4.2.tgz#f215fb88e8a1052f9f93083909e116d2b79fc8de"
nodemon@^1.11.0:
version "1.14.11"
resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.14.11.tgz#cc0009dd8d82f126f3aba50ace7e753827a8cebc"
@ -5149,7 +5159,7 @@ redis-commands@^1.2.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.3.1.tgz#81d826f45fa9c8b2011f4cd7a0fe597d241d442b"
redis-parser@^2.0.0:
redis-parser@^2.0.0, redis-parser@^2.6.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-2.6.0.tgz#52ed09dacac108f1a631c07e9b69941e7a19504b"
@ -5157,6 +5167,14 @@ redis@^0.12.1:
version "0.12.1"
resolved "https://registry.yarnpkg.com/redis/-/redis-0.12.1.tgz#64df76ad0fc8acebaebd2a0645e8a48fac49185e"
redis@^2.8.0:
version "2.8.0"
resolved "https://registry.yarnpkg.com/redis/-/redis-2.8.0.tgz#202288e3f58c49f6079d97af7a10e1303ae14b02"
dependencies:
double-ended-queue "^2.1.0-0"
redis-commands "^1.2.0"
redis-parser "^2.6.0"
redis@~2.6.0-2:
version "2.6.5"
resolved "https://registry.yarnpkg.com/redis/-/redis-2.6.5.tgz#87c1eff4a489f94b70871f3d08b6988f23a95687"