-
diff --git a/client/src/app/modal/confirm.component.ts b/client/src/app/modal/confirm.component.ts
index ec4e1d60f..3bb8b9b21 100644
--- a/client/src/app/modal/confirm.component.ts
+++ b/client/src/app/modal/confirm.component.ts
@@ -21,6 +21,8 @@ export class ConfirmComponent implements OnInit {
inputValue = ''
confirmButtonText = ''
+ isPasswordInput = false
+
private openedModal: NgbModalRef
constructor (
@@ -31,11 +33,27 @@ export class ConfirmComponent implements OnInit {
ngOnInit () {
this.confirmService.showConfirm.subscribe(
- ({ title, message, expectedInputValue, inputLabel, confirmButtonText }) => {
+ payload => {
+ // Reinit fields
+ this.title = ''
+ this.message = ''
+ this.expectedInputValue = ''
+ this.inputLabel = ''
+ this.inputValue = ''
+ this.confirmButtonText = ''
+ this.isPasswordInput = false
+
+ const { type, title, message, confirmButtonText } = payload
+
this.title = title
- this.inputLabel = inputLabel
- this.expectedInputValue = expectedInputValue
+ if (type === 'confirm-expected-input') {
+ this.inputLabel = payload.inputLabel
+ this.expectedInputValue = payload.expectedInputValue
+ } else if (type === 'confirm-password') {
+ this.inputLabel = $localize`Confirm your password`
+ this.isPasswordInput = true
+ }
this.confirmButtonText = confirmButtonText || $localize`Confirm`
@@ -66,11 +84,13 @@ export class ConfirmComponent implements OnInit {
this.openedModal = this.modalService.open(this.confirmModal, { centered: true })
this.openedModal.result
- .then(() => this.confirmService.confirmResponse.next(true))
+ .then(() => {
+ this.confirmService.confirmResponse.next({ confirmed: true, value: this.inputValue })
+ })
.catch((reason: string) => {
// If the reason was that the user used the back button, we don't care about the confirm dialog result
if (!reason || reason !== POP_STATE_MODAL_DISMISS) {
- this.confirmService.confirmResponse.next(false)
+ this.confirmService.confirmResponse.next({ confirmed: false, value: this.inputValue })
}
})
}
diff --git a/client/src/app/shared/form-validators/user-validators.ts b/client/src/app/shared/form-validators/user-validators.ts
index 3262853d8..b93de75ea 100644
--- a/client/src/app/shared/form-validators/user-validators.ts
+++ b/client/src/app/shared/form-validators/user-validators.ts
@@ -61,6 +61,15 @@ export const USER_EXISTING_PASSWORD_VALIDATOR: BuildFormValidator = {
}
}
+export const USER_OTP_TOKEN_VALIDATOR: BuildFormValidator = {
+ VALIDATORS: [
+ Validators.required
+ ],
+ MESSAGES: {
+ required: $localize`OTP token is required.`
+ }
+}
+
export const USER_PASSWORD_VALIDATOR = {
VALIDATORS: [
Validators.required,
diff --git a/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts
index d24a5d58d..12d503f56 100644
--- a/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts
+++ b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts
@@ -1,6 +1,6 @@
import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
import { AuthService, HtmlRendererService, Notifier } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
import { logger } from '@root-helpers/logger'
@@ -29,7 +29,7 @@ export class AbuseMessageModalComponent extends FormReactive implements OnInit {
private abuse: UserAbuse
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private htmlRenderer: HtmlRendererService,
private auth: AuthService,
diff --git a/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts
index 2600da8da..4ad807d25 100644
--- a/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts
+++ b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts
@@ -1,6 +1,6 @@
import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { Notifier } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { AbuseService } from '@app/shared/shared-moderation'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -20,7 +20,7 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
private openedModal: NgbModalRef
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private notifier: Notifier,
private abuseService: AbuseService
diff --git a/client/src/app/shared/shared-forms/form-reactive.service.ts b/client/src/app/shared/shared-forms/form-reactive.service.ts
new file mode 100644
index 000000000..f1b7e0ef2
--- /dev/null
+++ b/client/src/app/shared/shared-forms/form-reactive.service.ts
@@ -0,0 +1,101 @@
+import { Injectable } from '@angular/core'
+import { AbstractControl, FormGroup } from '@angular/forms'
+import { wait } from '@root-helpers/utils'
+import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
+import { FormValidatorService } from './form-validator.service'
+
+export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
+export type FormReactiveValidationMessages = {
+ [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
+}
+
+@Injectable()
+export class FormReactiveService {
+
+ constructor (private formValidatorService: FormValidatorService) {
+
+ }
+
+ buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) {
+ const { formErrors, validationMessages, form } = this.formValidatorService.buildForm(obj, defaultValues)
+
+ form.statusChanges.subscribe(async () => {
+ // FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed
+ await this.waitPendingCheck(form)
+
+ this.onStatusChanged({ form, formErrors, validationMessages })
+ })
+
+ return { form, formErrors, validationMessages }
+ }
+
+ async waitPendingCheck (form: FormGroup) {
+ if (form.status !== 'PENDING') return
+
+ // FIXME: the following line does not work: https://github.com/angular/angular/issues/41519
+ // return firstValueFrom(form.statusChanges.pipe(filter(status => status !== 'PENDING')))
+ // So we have to fallback to active wait :/
+
+ do {
+ await wait(10)
+ } while (form.status === 'PENDING')
+ }
+
+ markAllAsDirty (controlsArg: { [ key: string ]: AbstractControl }) {
+ const controls = controlsArg
+
+ for (const key of Object.keys(controls)) {
+ const control = controls[key]
+
+ if (control instanceof FormGroup) {
+ this.markAllAsDirty(control.controls)
+ continue
+ }
+
+ control.markAsDirty()
+ }
+ }
+
+ forceCheck (form: FormGroup, formErrors: any, validationMessages: FormReactiveValidationMessages) {
+ this.onStatusChanged({ form, formErrors, validationMessages, onlyDirty: false })
+ }
+
+ private onStatusChanged (options: {
+ form: FormGroup
+ formErrors: FormReactiveErrors
+ validationMessages: FormReactiveValidationMessages
+ onlyDirty?: boolean // default true
+ }) {
+ const { form, formErrors, validationMessages, onlyDirty = true } = options
+
+ for (const field of Object.keys(formErrors)) {
+ if (formErrors[field] && typeof formErrors[field] === 'object') {
+ this.onStatusChanged({
+ form: form.controls[field] as FormGroup,
+ formErrors: formErrors[field] as FormReactiveErrors,
+ validationMessages: validationMessages[field] as FormReactiveValidationMessages,
+ onlyDirty
+ })
+
+ continue
+ }
+
+ // clear previous error message (if any)
+ formErrors[field] = ''
+ const control = form.get(field)
+
+ if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue
+
+ const staticMessages = validationMessages[field]
+ for (const key of Object.keys(control.errors)) {
+ const formErrorValue = control.errors[key]
+
+ // Try to find error message in static validation messages first
+ // Then check if the validator returns a string that is the error
+ if (staticMessages[key]) formErrors[field] += staticMessages[key] + ' '
+ else if (typeof formErrorValue === 'string') formErrors[field] += control.errors[key]
+ else throw new Error('Form error value of ' + field + ' is invalid')
+ }
+ }
+ }
+}
diff --git a/client/src/app/shared/shared-forms/form-reactive.ts b/client/src/app/shared/shared-forms/form-reactive.ts
index a19ffdd82..d1e7be802 100644
--- a/client/src/app/shared/shared-forms/form-reactive.ts
+++ b/client/src/app/shared/shared-forms/form-reactive.ts
@@ -1,16 +1,9 @@
-
-import { AbstractControl, FormGroup } from '@angular/forms'
-import { wait } from '@root-helpers/utils'
+import { FormGroup } from '@angular/forms'
import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
-import { FormValidatorService } from './form-validator.service'
-
-export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
-export type FormReactiveValidationMessages = {
- [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
-}
+import { FormReactiveService, FormReactiveValidationMessages } from './form-reactive.service'
export abstract class FormReactive {
- protected abstract formValidatorService: FormValidatorService
+ protected abstract formReactiveService: FormReactiveService
protected formChanged = false
form: FormGroup
@@ -18,86 +11,22 @@ export abstract class FormReactive {
validationMessages: FormReactiveValidationMessages
buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) {
- const { formErrors, validationMessages, form } = this.formValidatorService.buildForm(obj, defaultValues)
+ const { formErrors, validationMessages, form } = this.formReactiveService.buildForm(obj, defaultValues)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
-
- this.form.statusChanges.subscribe(async () => {
- // FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed
- await this.waitPendingCheck()
-
- this.onStatusChanged(this.form, this.formErrors, this.validationMessages)
- })
}
protected async waitPendingCheck () {
- if (this.form.status !== 'PENDING') return
-
- // FIXME: the following line does not work: https://github.com/angular/angular/issues/41519
- // return firstValueFrom(this.form.statusChanges.pipe(filter(status => status !== 'PENDING')))
- // So we have to fallback to active wait :/
-
- do {
- await wait(10)
- } while (this.form.status === 'PENDING')
+ return this.formReactiveService.waitPendingCheck(this.form)
}
- protected markAllAsDirty (controlsArg?: { [ key: string ]: AbstractControl }) {
- const controls = controlsArg || this.form.controls
-
- for (const key of Object.keys(controls)) {
- const control = controls[key]
-
- if (control instanceof FormGroup) {
- this.markAllAsDirty(control.controls)
- continue
- }
-
- control.markAsDirty()
- }
+ protected markAllAsDirty () {
+ return this.formReactiveService.markAllAsDirty(this.form.controls)
}
protected forceCheck () {
- this.onStatusChanged(this.form, this.formErrors, this.validationMessages, false)
- }
-
- private onStatusChanged (
- form: FormGroup,
- formErrors: FormReactiveErrors,
- validationMessages: FormReactiveValidationMessages,
- onlyDirty = true
- ) {
- for (const field of Object.keys(formErrors)) {
- if (formErrors[field] && typeof formErrors[field] === 'object') {
- this.onStatusChanged(
- form.controls[field] as FormGroup,
- formErrors[field] as FormReactiveErrors,
- validationMessages[field] as FormReactiveValidationMessages,
- onlyDirty
- )
- continue
- }
-
- // clear previous error message (if any)
- formErrors[field] = ''
- const control = form.get(field)
-
- if (control.dirty) this.formChanged = true
-
- if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue
-
- const staticMessages = validationMessages[field]
- for (const key of Object.keys(control.errors)) {
- const formErrorValue = control.errors[key]
-
- // Try to find error message in static validation messages first
- // Then check if the validator returns a string that is the error
- if (staticMessages[key]) formErrors[field] += staticMessages[key] + ' '
- else if (typeof formErrorValue === 'string') formErrors[field] += control.errors[key]
- else throw new Error('Form error value of ' + field + ' is invalid')
- }
- }
+ return this.formReactiveService.forceCheck(this.form, this.formErrors, this.validationMessages)
}
}
diff --git a/client/src/app/shared/shared-forms/form-validator.service.ts b/client/src/app/shared/shared-forms/form-validator.service.ts
index f67d5bb33..897008242 100644
--- a/client/src/app/shared/shared-forms/form-validator.service.ts
+++ b/client/src/app/shared/shared-forms/form-validator.service.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'
import { AsyncValidatorFn, FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms'
import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
-import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive'
+import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive.service'
@Injectable()
export class FormValidatorService {
diff --git a/client/src/app/shared/shared-forms/index.ts b/client/src/app/shared/shared-forms/index.ts
index 495785e7b..bff9862f2 100644
--- a/client/src/app/shared/shared-forms/index.ts
+++ b/client/src/app/shared/shared-forms/index.ts
@@ -1,4 +1,5 @@
export * from './advanced-input-filter.component'
+export * from './form-reactive.service'
export * from './form-reactive'
export * from './form-validator.service'
export * from './form-validator.service'
diff --git a/client/src/app/shared/shared-forms/input-text.component.ts b/client/src/app/shared/shared-forms/input-text.component.ts
index d667ed663..aa4a1cba8 100644
--- a/client/src/app/shared/shared-forms/input-text.component.ts
+++ b/client/src/app/shared/shared-forms/input-text.component.ts
@@ -1,4 +1,4 @@
-import { Component, forwardRef, Input } from '@angular/core'
+import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { Notifier } from '@app/core'
@@ -15,6 +15,8 @@ import { Notifier } from '@app/core'
]
})
export class InputTextComponent implements ControlValueAccessor {
+ @ViewChild('input') inputElement: ElementRef
+
@Input() inputId = Math.random().toString(11).slice(2, 8) // id cannot be left empty or undefined
@Input() value = ''
@Input() autocomplete = 'off'
@@ -65,4 +67,10 @@ export class InputTextComponent implements ControlValueAccessor {
update () {
this.propagateChange(this.value)
}
+
+ focus () {
+ const el: HTMLElement = this.inputElement.nativeElement
+
+ el.focus({ preventScroll: true })
+ }
}
diff --git a/client/src/app/shared/shared-forms/shared-form.module.ts b/client/src/app/shared/shared-forms/shared-form.module.ts
index 81f076db6..628affb56 100644
--- a/client/src/app/shared/shared-forms/shared-form.module.ts
+++ b/client/src/app/shared/shared-forms/shared-form.module.ts
@@ -1,4 +1,3 @@
-
import { InputMaskModule } from 'primeng/inputmask'
import { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
@@ -7,6 +6,7 @@ import { SharedGlobalIconModule } from '../shared-icons'
import { SharedMainModule } from '../shared-main/shared-main.module'
import { AdvancedInputFilterComponent } from './advanced-input-filter.component'
import { DynamicFormFieldComponent } from './dynamic-form-field.component'
+import { FormReactiveService } from './form-reactive.service'
import { FormValidatorService } from './form-validator.service'
import { InputSwitchComponent } from './input-switch.component'
import { InputTextComponent } from './input-text.component'
@@ -96,7 +96,8 @@ import { TimestampInputComponent } from './timestamp-input.component'
],
providers: [
- FormValidatorService
+ FormValidatorService,
+ FormReactiveService
]
})
export class SharedFormModule { }
diff --git a/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts b/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts
index e4b74f3ad..93b3a93d6 100644
--- a/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts
+++ b/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts
@@ -27,13 +27,16 @@ export class AuthInterceptor implements HttpInterceptor {
.pipe(
catchError((err: HttpErrorResponse) => {
const error = err.error as PeerTubeProblemDocument
+ const isOTPMissingError = this.authService.isOTPMissingError(err)
- if (err.status === HttpStatusCode.UNAUTHORIZED_401 && error && error.code === OAuth2ErrorCode.INVALID_TOKEN) {
- return this.handleTokenExpired(req, next)
- }
+ if (!isOTPMissingError) {
+ if (err.status === HttpStatusCode.UNAUTHORIZED_401 && error && error.code === OAuth2ErrorCode.INVALID_TOKEN) {
+ return this.handleTokenExpired(req, next)
+ }
- if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
- return this.handleNotAuthenticated(err)
+ if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
+ return this.handleNotAuthenticated(err)
+ }
}
return observableThrowError(() => err)
diff --git a/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts b/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts
index 20be728f6..ec2fea528 100644
--- a/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts
+++ b/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts
@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
import { splitAndGetNotEmpty, UNIQUE_HOSTS_VALIDATOR } from '../form-validators/host-validators'
@@ -18,7 +18,7 @@ export class BatchDomainsModalComponent extends FormReactive implements OnInit {
private openedModal: NgbModalRef
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal
) {
super()
diff --git a/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts
index 78c9b3382..d587a9709 100644
--- a/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts
+++ b/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts
@@ -2,7 +2,7 @@ import { mapValues, pickBy } from 'lodash-es'
import { Component, OnInit, ViewChild } from '@angular/core'
import { Notifier } from '@app/core'
import { ABUSE_REASON_VALIDATOR } from '@app/shared/form-validators/abuse-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { Account } from '@app/shared/shared-main'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -26,7 +26,7 @@ export class AccountReportComponent extends FormReactive implements OnInit {
private openedModal: NgbModalRef
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private abuseService: AbuseService,
private notifier: Notifier
diff --git a/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts
index 7c0907ce4..e35d70c8f 100644
--- a/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts
+++ b/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts
@@ -2,7 +2,7 @@ import { mapValues, pickBy } from 'lodash-es'
import { Component, Input, OnInit, ViewChild } from '@angular/core'
import { Notifier } from '@app/core'
import { ABUSE_REASON_VALIDATOR } from '@app/shared/form-validators/abuse-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { VideoComment } from '@app/shared/shared-video-comment'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -27,7 +27,7 @@ export class CommentReportComponent extends FormReactive implements OnInit {
private openedModal: NgbModalRef
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private abuseService: AbuseService,
private notifier: Notifier
diff --git a/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts
index 38dd92910..16be8e0a1 100644
--- a/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts
+++ b/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts
@@ -3,7 +3,7 @@ import { Component, Input, OnInit, ViewChild } from '@angular/core'
import { DomSanitizer } from '@angular/platform-browser'
import { Notifier } from '@app/core'
import { ABUSE_REASON_VALIDATOR } from '@app/shared/form-validators/abuse-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
import { abusePredefinedReasonsMap } from '@shared/core-utils/abuse'
@@ -27,7 +27,7 @@ export class VideoReportComponent extends FormReactive implements OnInit {
private openedModal: NgbModalRef
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private abuseService: AbuseService,
private notifier: Notifier,
diff --git a/client/src/app/shared/shared-moderation/user-ban-modal.component.ts b/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
index 617408f2a..27dcf043a 100644
--- a/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
+++ b/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
@@ -2,7 +2,7 @@ import { forkJoin } from 'rxjs'
import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { Notifier } from '@app/core'
import { prepareIcu } from '@app/helpers'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
import { User } from '@shared/models'
@@ -25,7 +25,7 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
modalMessage = ''
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private notifier: Notifier,
private userAdminService: UserAdminService,
diff --git a/client/src/app/shared/shared-moderation/video-block.component.ts b/client/src/app/shared/shared-moderation/video-block.component.ts
index f8b22a3f6..3ff53443a 100644
--- a/client/src/app/shared/shared-moderation/video-block.component.ts
+++ b/client/src/app/shared/shared-moderation/video-block.component.ts
@@ -1,7 +1,7 @@
import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { Notifier } from '@app/core'
import { prepareIcu } from '@app/helpers'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { Video } from '@app/shared/shared-main'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -25,7 +25,7 @@ export class VideoBlockComponent extends FormReactive implements OnInit {
private openedModal: NgbModalRef
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private videoBlocklistService: VideoBlockService,
private notifier: Notifier
diff --git a/client/src/app/shared/shared-user-settings/user-interface-settings.component.ts b/client/src/app/shared/shared-user-settings/user-interface-settings.component.ts
index 13e2e5424..c2c30d38b 100644
--- a/client/src/app/shared/shared-user-settings/user-interface-settings.component.ts
+++ b/client/src/app/shared/shared-user-settings/user-interface-settings.component.ts
@@ -1,7 +1,7 @@
import { Subject, Subscription } from 'rxjs'
import { Component, Input, OnDestroy, OnInit } from '@angular/core'
import { AuthService, Notifier, ServerService, ThemeService, UserService } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { HTMLServerConfig, User, UserUpdateMe } from '@shared/models'
import { SelectOptionsItem } from 'src/types'
@@ -22,7 +22,7 @@ export class UserInterfaceSettingsComponent extends FormReactive implements OnIn
private serverConfig: HTMLServerConfig
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private authService: AuthService,
private notifier: Notifier,
private userService: UserService,
diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.ts b/client/src/app/shared/shared-user-settings/user-video-settings.component.ts
index 7d6b69469..af0870f12 100644
--- a/client/src/app/shared/shared-user-settings/user-video-settings.component.ts
+++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.ts
@@ -3,7 +3,7 @@ import { Subject, Subscription } from 'rxjs'
import { first } from 'rxjs/operators'
import { Component, Input, OnDestroy, OnInit } from '@angular/core'
import { AuthService, Notifier, ServerService, User, UserService } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { UserUpdateMe } from '@shared/models'
import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
@@ -22,7 +22,7 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
formValuesWatcher: Subscription
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private authService: AuthService,
private notifier: Notifier,
private userService: UserService,
diff --git a/client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts b/client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts
index 7bcfdd8aa..61bcd5345 100644
--- a/client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts
+++ b/client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts
@@ -1,6 +1,6 @@
import { Component, Input, OnInit } from '@angular/core'
import { Notifier } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { logger } from '@root-helpers/logger'
import { USER_HANDLE_VALIDATOR } from '../form-validators/user-validators'
@@ -15,7 +15,7 @@ export class RemoteSubscribeComponent extends FormReactive implements OnInit {
@Input() showHelp = false
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private notifier: Notifier
) {
super()
diff --git a/client/src/app/shared/shared-users/index.ts b/client/src/app/shared/shared-users/index.ts
index 8f90f2515..20e60486d 100644
--- a/client/src/app/shared/shared-users/index.ts
+++ b/client/src/app/shared/shared-users/index.ts
@@ -1,4 +1,5 @@
export * from './user-admin.service'
export * from './user-signup.service'
+export * from './two-factor.service'
export * from './shared-users.module'
diff --git a/client/src/app/shared/shared-users/shared-users.module.ts b/client/src/app/shared/shared-users/shared-users.module.ts
index 2a1dadf20..5a1675dc9 100644
--- a/client/src/app/shared/shared-users/shared-users.module.ts
+++ b/client/src/app/shared/shared-users/shared-users.module.ts
@@ -1,6 +1,7 @@
import { NgModule } from '@angular/core'
import { SharedMainModule } from '../shared-main/shared-main.module'
+import { TwoFactorService } from './two-factor.service'
import { UserAdminService } from './user-admin.service'
import { UserSignupService } from './user-signup.service'
@@ -15,7 +16,8 @@ import { UserSignupService } from './user-signup.service'
providers: [
UserSignupService,
- UserAdminService
+ UserAdminService,
+ TwoFactorService
]
})
export class SharedUsersModule { }
diff --git a/client/src/app/shared/shared-users/two-factor.service.ts b/client/src/app/shared/shared-users/two-factor.service.ts
new file mode 100644
index 000000000..9ff916f15
--- /dev/null
+++ b/client/src/app/shared/shared-users/two-factor.service.ts
@@ -0,0 +1,52 @@
+import { catchError } from 'rxjs/operators'
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, UserService } from '@app/core'
+import { TwoFactorEnableResult } from '@shared/models'
+
+@Injectable()
+export class TwoFactorService {
+ constructor (
+ private authHttp: HttpClient,
+ private restExtractor: RestExtractor
+ ) { }
+
+ // ---------------------------------------------------------------------------
+
+ requestTwoFactor (options: {
+ userId: number
+ currentPassword: string
+ }) {
+ const { userId, currentPassword } = options
+
+ const url = UserService.BASE_USERS_URL + userId + '/two-factor/request'
+
+ return this.authHttp.post
(url, { currentPassword })
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ confirmTwoFactorRequest (options: {
+ userId: number
+ requestToken: string
+ otpToken: string
+ }) {
+ const { userId, requestToken, otpToken } = options
+
+ const url = UserService.BASE_USERS_URL + userId + '/two-factor/confirm-request'
+
+ return this.authHttp.post(url, { requestToken, otpToken })
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ disableTwoFactor (options: {
+ userId: number
+ currentPassword?: string
+ }) {
+ const { userId, currentPassword } = options
+
+ const url = UserService.BASE_USERS_URL + userId + '/two-factor/disable'
+
+ return this.authHttp.post(url, { currentPassword })
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+}
diff --git a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
index e019fdd26..f81de7c6b 100644
--- a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
+++ b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
@@ -3,7 +3,7 @@ import { Subject, Subscription } from 'rxjs'
import { debounceTime, filter } from 'rxjs/operators'
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'
import { AuthService, DisableForReuseHook, Notifier } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { secondsToTime } from '@shared/core-utils'
import {
Video,
@@ -59,7 +59,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
private pendingAddId: number
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private authService: AuthService,
private notifier: Notifier,
private videoPlaylistService: VideoPlaylistService,
diff --git a/client/src/sass/bootstrap.scss b/client/src/sass/bootstrap.scss
index 9328a27a2..a5d06de98 100644
--- a/client/src/sass/bootstrap.scss
+++ b/client/src/sass/bootstrap.scss
@@ -3,32 +3,32 @@
@import './_bootstrap-variables';
-@import '~bootstrap/scss/functions';
-@import '~bootstrap/scss/variables';
-@import '~bootstrap/scss/maps';
-@import '~bootstrap/scss/mixins';
-@import '~bootstrap/scss/utilities';
+@import 'bootstrap/scss/functions';
+@import 'bootstrap/scss/variables';
+@import 'bootstrap/scss/maps';
+@import 'bootstrap/scss/mixins';
+@import 'bootstrap/scss/utilities';
-@import '~bootstrap/scss/root';
-@import '~bootstrap/scss/reboot';
-@import '~bootstrap/scss/type';
-@import '~bootstrap/scss/grid';
-@import '~bootstrap/scss/forms';
-@import '~bootstrap/scss/buttons';
-@import '~bootstrap/scss/dropdown';
-@import '~bootstrap/scss/button-group';
-@import '~bootstrap/scss/nav';
-@import '~bootstrap/scss/card';
-@import '~bootstrap/scss/accordion';
-@import '~bootstrap/scss/alert';
-@import '~bootstrap/scss/close';
-@import '~bootstrap/scss/modal';
-@import '~bootstrap/scss/tooltip';
-@import '~bootstrap/scss/popover';
-@import '~bootstrap/scss/spinners';
+@import 'bootstrap/scss/root';
+@import 'bootstrap/scss/reboot';
+@import 'bootstrap/scss/type';
+@import 'bootstrap/scss/grid';
+@import 'bootstrap/scss/forms';
+@import 'bootstrap/scss/buttons';
+@import 'bootstrap/scss/dropdown';
+@import 'bootstrap/scss/button-group';
+@import 'bootstrap/scss/nav';
+@import 'bootstrap/scss/card';
+@import 'bootstrap/scss/accordion';
+@import 'bootstrap/scss/alert';
+@import 'bootstrap/scss/close';
+@import 'bootstrap/scss/modal';
+@import 'bootstrap/scss/tooltip';
+@import 'bootstrap/scss/popover';
+@import 'bootstrap/scss/spinners';
-@import '~bootstrap/scss/helpers';
-@import '~bootstrap/scss/utilities/api';
+@import 'bootstrap/scss/helpers';
+@import 'bootstrap/scss/utilities/api';
.accordion {
--bs-accordion-color: #{pvar(--mainForegroundColor)};
diff --git a/client/src/sass/include/_variables.scss b/client/src/sass/include/_variables.scss
index c02359f28..02fa7f1f0 100644
--- a/client/src/sass/include/_variables.scss
+++ b/client/src/sass/include/_variables.scss
@@ -1,6 +1,6 @@
@use 'sass:math';
@use 'sass:color';
-@use '~bootstrap/scss/functions' as *;
+@use 'bootstrap/scss/functions' as *;
$small-view: 800px;
$mobile-view: 500px;
diff --git a/client/src/sass/ng-select.scss b/client/src/sass/ng-select.scss
index 78e3a6de3..e231e4fed 100644
--- a/client/src/sass/ng-select.scss
+++ b/client/src/sass/ng-select.scss
@@ -15,7 +15,7 @@ $ng-select-height: 30px;
$ng-select-value-padding-left: 15px;
$ng-select-value-font-size: $form-input-font-size;
-@import '~@ng-select/ng-select/scss/default.theme';
+@import '@ng-select/ng-select/scss/default.theme';
.ng-select {
font-size: $ng-select-value-font-size;
diff --git a/client/src/sass/player/_player-variables.scss b/client/src/sass/player/_player-variables.scss
index 47b8adda4..d5f24dd91 100644
--- a/client/src/sass/player/_player-variables.scss
+++ b/client/src/sass/player/_player-variables.scss
@@ -1,4 +1,4 @@
-@use '~bootstrap/scss/functions' as *;
+@use 'bootstrap/scss/functions' as *;
$primary-foreground-color: #fff;
$primary-foreground-opacity: 0.9;
diff --git a/config/default.yaml b/config/default.yaml
index 2d8aaf1ea..f94ec6209 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -10,6 +10,11 @@ webserver:
hostname: 'localhost'
port: 9000
+# Secrets you need to generate the first time you run PeerTube
+secrets:
+ # Generate one using `openssl rand -hex 32`
+ peertube: ''
+
rates_limit:
api:
# 50 attempts in 10 seconds
diff --git a/config/dev.yaml b/config/dev.yaml
index ca93874d2..ef93afc19 100644
--- a/config/dev.yaml
+++ b/config/dev.yaml
@@ -5,6 +5,9 @@ listen:
webserver:
https: false
+secrets:
+ peertube: 'my super dev secret'
+
database:
hostname: 'localhost'
port: 5432
diff --git a/config/production.yaml.example b/config/production.yaml.example
index 46d574e42..e37ff9b8a 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -8,6 +8,11 @@ webserver:
hostname: 'example.com'
port: 443
+# Secrets you need to generate the first time you run PeerTube
+secret:
+ # Generate one using `openssl rand -hex 32`
+ peertube: ''
+
rates_limit:
api:
# 50 attempts in 10 seconds
diff --git a/config/test.yaml b/config/test.yaml
index a87642bd8..48cf0c0f6 100644
--- a/config/test.yaml
+++ b/config/test.yaml
@@ -5,6 +5,9 @@ listen:
webserver:
https: false
+secrets:
+ peertube: 'my super secret'
+
rates_limit:
signup:
window: 10 minutes
diff --git a/package.json b/package.json
index dd913896d..6dcf26253 100644
--- a/package.json
+++ b/package.json
@@ -147,6 +147,7 @@
"node-media-server": "^2.1.4",
"nodemailer": "^6.0.0",
"opentelemetry-instrumentation-sequelize": "^0.29.0",
+ "otpauth": "^8.0.3",
"p-queue": "^6",
"parse-torrent": "^9.1.0",
"password-generator": "^2.0.2",
diff --git a/server.ts b/server.ts
index 2085c67d9..417387a4f 100644
--- a/server.ts
+++ b/server.ts
@@ -45,7 +45,12 @@ try {
import { checkConfig, checkActivityPubUrls, checkFFmpegVersion } from './server/initializers/checker-after-init'
-checkConfig()
+try {
+ checkConfig()
+} catch (err) {
+ logger.error('Config error.', { err })
+ process.exit(-1)
+}
// Trust our proxy (IP forwarding...)
app.set('trust proxy', CONFIG.TRUST_PROXY)
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
index 07b9ae395..a8677a1d3 100644
--- a/server/controllers/api/users/index.ts
+++ b/server/controllers/api/users/index.ts
@@ -51,6 +51,7 @@ import { myVideosHistoryRouter } from './my-history'
import { myNotificationsRouter } from './my-notifications'
import { mySubscriptionsRouter } from './my-subscriptions'
import { myVideoPlaylistsRouter } from './my-video-playlists'
+import { twoFactorRouter } from './two-factor'
const auditLogger = auditLoggerFactory('users')
@@ -66,6 +67,7 @@ const askSendEmailLimiter = buildRateLimiter({
})
const usersRouter = express.Router()
+usersRouter.use('/', twoFactorRouter)
usersRouter.use('/', tokensRouter)
usersRouter.use('/', myNotificationsRouter)
usersRouter.use('/', mySubscriptionsRouter)
diff --git a/server/controllers/api/users/token.ts b/server/controllers/api/users/token.ts
index 012a49791..c6afea67c 100644
--- a/server/controllers/api/users/token.ts
+++ b/server/controllers/api/users/token.ts
@@ -1,8 +1,9 @@
import express from 'express'
import { logger } from '@server/helpers/logger'
import { CONFIG } from '@server/initializers/config'
+import { OTP } from '@server/initializers/constants'
import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth'
-import { handleOAuthToken } from '@server/lib/auth/oauth'
+import { handleOAuthToken, MissingTwoFactorError } from '@server/lib/auth/oauth'
import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model'
import { Hooks } from '@server/lib/plugins/hooks'
import { asyncMiddleware, authenticate, buildRateLimiter, openapiOperationDoc } from '@server/middlewares'
@@ -79,6 +80,10 @@ async function handleToken (req: express.Request, res: express.Response, next: e
} catch (err) {
logger.warn('Login error', { err })
+ if (err instanceof MissingTwoFactorError) {
+ res.set(OTP.HEADER_NAME, OTP.HEADER_REQUIRED_VALUE)
+ }
+
return res.fail({
status: err.code,
message: err.message,
diff --git a/server/controllers/api/users/two-factor.ts b/server/controllers/api/users/two-factor.ts
new file mode 100644
index 000000000..e6ae9e4dd
--- /dev/null
+++ b/server/controllers/api/users/two-factor.ts
@@ -0,0 +1,95 @@
+import express from 'express'
+import { generateOTPSecret, isOTPValid } from '@server/helpers/otp'
+import { encrypt } from '@server/helpers/peertube-crypto'
+import { CONFIG } from '@server/initializers/config'
+import { Redis } from '@server/lib/redis'
+import { asyncMiddleware, authenticate, usersCheckCurrentPasswordFactory } from '@server/middlewares'
+import {
+ confirmTwoFactorValidator,
+ disableTwoFactorValidator,
+ requestOrConfirmTwoFactorValidator
+} from '@server/middlewares/validators/two-factor'
+import { HttpStatusCode, TwoFactorEnableResult } from '@shared/models'
+
+const twoFactorRouter = express.Router()
+
+twoFactorRouter.post('/:id/two-factor/request',
+ authenticate,
+ asyncMiddleware(usersCheckCurrentPasswordFactory(req => req.params.id)),
+ asyncMiddleware(requestOrConfirmTwoFactorValidator),
+ asyncMiddleware(requestTwoFactor)
+)
+
+twoFactorRouter.post('/:id/two-factor/confirm-request',
+ authenticate,
+ asyncMiddleware(requestOrConfirmTwoFactorValidator),
+ confirmTwoFactorValidator,
+ asyncMiddleware(confirmRequestTwoFactor)
+)
+
+twoFactorRouter.post('/:id/two-factor/disable',
+ authenticate,
+ asyncMiddleware(usersCheckCurrentPasswordFactory(req => req.params.id)),
+ asyncMiddleware(disableTwoFactorValidator),
+ asyncMiddleware(disableTwoFactor)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+ twoFactorRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function requestTwoFactor (req: express.Request, res: express.Response) {
+ const user = res.locals.user
+
+ const { secret, uri } = generateOTPSecret(user.email)
+
+ const encryptedSecret = await encrypt(secret, CONFIG.SECRETS.PEERTUBE)
+ const requestToken = await Redis.Instance.setTwoFactorRequest(user.id, encryptedSecret)
+
+ return res.json({
+ otpRequest: {
+ requestToken,
+ secret,
+ uri
+ }
+ } as TwoFactorEnableResult)
+}
+
+async function confirmRequestTwoFactor (req: express.Request, res: express.Response) {
+ const requestToken = req.body.requestToken
+ const otpToken = req.body.otpToken
+ const user = res.locals.user
+
+ const encryptedSecret = await Redis.Instance.getTwoFactorRequestToken(user.id, requestToken)
+ if (!encryptedSecret) {
+ return res.fail({
+ message: 'Invalid request token',
+ status: HttpStatusCode.FORBIDDEN_403
+ })
+ }
+
+ if (await isOTPValid({ encryptedSecret, token: otpToken }) !== true) {
+ return res.fail({
+ message: 'Invalid OTP token',
+ status: HttpStatusCode.FORBIDDEN_403
+ })
+ }
+
+ user.otpSecret = encryptedSecret
+ await user.save()
+
+ return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+async function disableTwoFactor (req: express.Request, res: express.Response) {
+ const user = res.locals.user
+
+ user.otpSecret = null
+ await user.save()
+
+ return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts
index c762f6a29..73bd994c1 100644
--- a/server/helpers/core-utils.ts
+++ b/server/helpers/core-utils.ts
@@ -6,7 +6,7 @@
*/
import { exec, ExecOptions } from 'child_process'
-import { ED25519KeyPairOptions, generateKeyPair, randomBytes, RSAKeyPairOptions } from 'crypto'
+import { ED25519KeyPairOptions, generateKeyPair, randomBytes, RSAKeyPairOptions, scrypt } from 'crypto'
import { truncate } from 'lodash'
import { pipeline } from 'stream'
import { URL } from 'url'
@@ -311,7 +311,17 @@ function promisify2 (func: (arg1: T, arg2: U, cb: (err: any, result: A)
}
}
+// eslint-disable-next-line max-len
+function promisify3 (func: (arg1: T, arg2: U, arg3: V, cb: (err: any, result: A) => void) => void): (arg1: T, arg2: U, arg3: V) => Promise {
+ return function promisified (arg1: T, arg2: U, arg3: V): Promise {
+ return new Promise((resolve: (arg: A) => void, reject: (err: any) => void) => {
+ func.apply(null, [ arg1, arg2, arg3, (err: any, res: A) => err ? reject(err) : resolve(res) ])
+ })
+ }
+}
+
const randomBytesPromise = promisify1(randomBytes)
+const scryptPromise = promisify3(scrypt)
const execPromise2 = promisify2(exec)
const execPromise = promisify1(exec)
const pipelinePromise = promisify(pipeline)
@@ -339,6 +349,8 @@ export {
promisify1,
promisify2,
+ scryptPromise,
+
randomBytesPromise,
generateRSAKeyPairPromise,
diff --git a/server/helpers/otp.ts b/server/helpers/otp.ts
new file mode 100644
index 000000000..a32cc9621
--- /dev/null
+++ b/server/helpers/otp.ts
@@ -0,0 +1,58 @@
+import { Secret, TOTP } from 'otpauth'
+import { CONFIG } from '@server/initializers/config'
+import { WEBSERVER } from '@server/initializers/constants'
+import { decrypt } from './peertube-crypto'
+
+async function isOTPValid (options: {
+ encryptedSecret: string
+ token: string
+}) {
+ const { token, encryptedSecret } = options
+
+ const secret = await decrypt(encryptedSecret, CONFIG.SECRETS.PEERTUBE)
+
+ const totp = new TOTP({
+ ...baseOTPOptions(),
+
+ secret
+ })
+
+ const delta = totp.validate({
+ token,
+ window: 1
+ })
+
+ if (delta === null) return false
+
+ return true
+}
+
+function generateOTPSecret (email: string) {
+ const totp = new TOTP({
+ ...baseOTPOptions(),
+
+ label: email,
+ secret: new Secret()
+ })
+
+ return {
+ secret: totp.secret.base32,
+ uri: totp.toString()
+ }
+}
+
+export {
+ isOTPValid,
+ generateOTPSecret
+}
+
+// ---------------------------------------------------------------------------
+
+function baseOTPOptions () {
+ return {
+ issuer: WEBSERVER.HOST,
+ algorithm: 'SHA1',
+ digits: 6,
+ period: 30
+ }
+}
diff --git a/server/helpers/peertube-crypto.ts b/server/helpers/peertube-crypto.ts
index 8aca50900..ae7d11800 100644
--- a/server/helpers/peertube-crypto.ts
+++ b/server/helpers/peertube-crypto.ts
@@ -1,11 +1,11 @@
import { compare, genSalt, hash } from 'bcrypt'
-import { createSign, createVerify } from 'crypto'
+import { createCipheriv, createDecipheriv, createSign, createVerify } from 'crypto'
import { Request } from 'express'
import { cloneDeep } from 'lodash'
import { sha256 } from '@shared/extra-utils'
-import { BCRYPT_SALT_SIZE, HTTP_SIGNATURE, PRIVATE_RSA_KEY_SIZE } from '../initializers/constants'
+import { BCRYPT_SALT_SIZE, ENCRYPTION, HTTP_SIGNATURE, PRIVATE_RSA_KEY_SIZE } from '../initializers/constants'
import { MActor } from '../types/models'
-import { generateRSAKeyPairPromise, promisify1, promisify2 } from './core-utils'
+import { generateRSAKeyPairPromise, promisify1, promisify2, randomBytesPromise, scryptPromise } from './core-utils'
import { jsonld } from './custom-jsonld-signature'
import { logger } from './logger'
@@ -21,9 +21,13 @@ function createPrivateAndPublicKeys () {
return generateRSAKeyPairPromise(PRIVATE_RSA_KEY_SIZE)
}
+// ---------------------------------------------------------------------------
// User password checks
+// ---------------------------------------------------------------------------
function comparePassword (plainPassword: string, hashPassword: string) {
+ if (!plainPassword) return Promise.resolve(false)
+
return bcryptComparePromise(plainPassword, hashPassword)
}
@@ -33,7 +37,9 @@ async function cryptPassword (password: string) {
return bcryptHashPromise(password, salt)
}
+// ---------------------------------------------------------------------------
// HTTP Signature
+// ---------------------------------------------------------------------------
function isHTTPSignatureDigestValid (rawBody: Buffer, req: Request): boolean {
if (req.headers[HTTP_SIGNATURE.HEADER_NAME] && req.headers['digest']) {
@@ -62,7 +68,9 @@ function parseHTTPSignature (req: Request, clockSkew?: number) {
return parsed
}
+// ---------------------------------------------------------------------------
// JSONLD
+// ---------------------------------------------------------------------------
function isJsonLDSignatureVerified (fromActor: MActor, signedDocument: any): Promise {
if (signedDocument.signature.type === 'RsaSignature2017') {
@@ -112,12 +120,42 @@ async function signJsonLDObject (byActor: MActor, data: T) {
return Object.assign(data, { signature })
}
+// ---------------------------------------------------------------------------
+
function buildDigest (body: any) {
const rawBody = typeof body === 'string' ? body : JSON.stringify(body)
return 'SHA-256=' + sha256(rawBody, 'base64')
}
+// ---------------------------------------------------------------------------
+// Encryption
+// ---------------------------------------------------------------------------
+
+async function encrypt (str: string, secret: string) {
+ const iv = await randomBytesPromise(ENCRYPTION.IV)
+
+ const key = await scryptPromise(secret, ENCRYPTION.SALT, 32)
+ const cipher = createCipheriv(ENCRYPTION.ALGORITHM, key, iv)
+
+ let encrypted = iv.toString(ENCRYPTION.ENCODING) + ':'
+ encrypted += cipher.update(str, 'utf8', ENCRYPTION.ENCODING)
+ encrypted += cipher.final(ENCRYPTION.ENCODING)
+
+ return encrypted
+}
+
+async function decrypt (encryptedArg: string, secret: string) {
+ const [ ivStr, encryptedStr ] = encryptedArg.split(':')
+
+ const iv = Buffer.from(ivStr, 'hex')
+ const key = await scryptPromise(secret, ENCRYPTION.SALT, 32)
+
+ const decipher = createDecipheriv(ENCRYPTION.ALGORITHM, key, iv)
+
+ return decipher.update(encryptedStr, ENCRYPTION.ENCODING, 'utf8') + decipher.final('utf8')
+}
+
// ---------------------------------------------------------------------------
export {
@@ -129,7 +167,10 @@ export {
comparePassword,
createPrivateAndPublicKeys,
cryptPassword,
- signJsonLDObject
+ signJsonLDObject,
+
+ encrypt,
+ decrypt
}
// ---------------------------------------------------------------------------
diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts
index 42839d1c9..c83fef425 100644
--- a/server/initializers/checker-after-init.ts
+++ b/server/initializers/checker-after-init.ts
@@ -42,6 +42,7 @@ function checkConfig () {
logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.')
}
+ checkSecretsConfig()
checkEmailConfig()
checkNSFWPolicyConfig()
checkLocalRedundancyConfig()
@@ -103,6 +104,12 @@ export {
// ---------------------------------------------------------------------------
+function checkSecretsConfig () {
+ if (!CONFIG.SECRETS.PEERTUBE) {
+ throw new Error('secrets.peertube is missing in config. Generate one using `openssl rand -hex 32`')
+ }
+}
+
function checkEmailConfig () {
if (!isEmailEnabled()) {
if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index 1fd4ba248..c9268b156 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -11,6 +11,7 @@ const config: IConfig = require('config')
function checkMissedConfig () {
const required = [ 'listen.port', 'listen.hostname',
'webserver.https', 'webserver.hostname', 'webserver.port',
+ 'secrets.peertube',
'trust_proxy',
'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max',
'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index 287bf6f6d..a5a0d4e46 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -20,6 +20,9 @@ const CONFIG = {
PORT: config.get('listen.port'),
HOSTNAME: config.get('listen.hostname')
},
+ SECRETS: {
+ PEERTUBE: config.get('secrets.peertube')
+ },
DATABASE: {
DBNAME: config.has('database.name') ? config.get('database.name') : 'peertube' + config.get('database.suffix'),
HOSTNAME: config.get('database.hostname'),
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 9257ebf93..cab61948a 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -1,5 +1,5 @@
import { RepeatOptions } from 'bullmq'
-import { randomBytes } from 'crypto'
+import { Encoding, randomBytes } from 'crypto'
import { invert } from 'lodash'
import { join } from 'path'
import { randomInt, root } from '@shared/core-utils'
@@ -25,7 +25,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
// ---------------------------------------------------------------------------
-const LAST_MIGRATION_VERSION = 740
+const LAST_MIGRATION_VERSION = 745
// ---------------------------------------------------------------------------
@@ -637,9 +637,18 @@ let PRIVATE_RSA_KEY_SIZE = 2048
// Password encryption
const BCRYPT_SALT_SIZE = 10
+const ENCRYPTION = {
+ ALGORITHM: 'aes-256-cbc',
+ IV: 16,
+ SALT: 'peertube',
+ ENCODING: 'hex' as Encoding
+}
+
const USER_PASSWORD_RESET_LIFETIME = 60000 * 60 // 60 minutes
const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days
+const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes
+
const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = {
@@ -805,6 +814,10 @@ const REDUNDANCY = {
}
const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS)
+const OTP = {
+ HEADER_NAME: 'x-peertube-otp',
+ HEADER_REQUIRED_VALUE: 'required; app'
+}
const ASSETS_PATH = {
DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg'),
@@ -953,6 +966,7 @@ const VIDEO_FILTERS = {
export {
WEBSERVER,
API_VERSION,
+ ENCRYPTION,
VIDEO_LIVE,
PEERTUBE_VERSION,
LAZY_STATIC_PATHS,
@@ -986,6 +1000,7 @@ export {
FOLLOW_STATES,
DEFAULT_USER_THEME_NAME,
SERVER_ACTOR_NAME,
+ TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
PLUGIN_GLOBAL_CSS_FILE_NAME,
PLUGIN_GLOBAL_CSS_PATH,
PRIVATE_RSA_KEY_SIZE,
@@ -1041,6 +1056,7 @@ export {
PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME,
ASSETS_PATH,
FILES_CONTENT_HASH,
+ OTP,
loadLanguages,
buildLanguages,
generateContentHash
diff --git a/server/initializers/migrations/0745-user-otp.ts b/server/initializers/migrations/0745-user-otp.ts
new file mode 100644
index 000000000..157308ea1
--- /dev/null
+++ b/server/initializers/migrations/0745-user-otp.ts
@@ -0,0 +1,29 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+ transaction: Sequelize.Transaction
+ queryInterface: Sequelize.QueryInterface
+ sequelize: Sequelize.Sequelize
+ db: any
+}): Promise {
+ const { transaction } = utils
+
+ const data = {
+ type: Sequelize.STRING,
+ defaultValue: null,
+ allowNull: true
+ }
+ await utils.queryInterface.addColumn('user', 'otpSecret', data, { transaction })
+
+}
+
+async function down (utils: {
+ queryInterface: Sequelize.QueryInterface
+ transaction: Sequelize.Transaction
+}) {
+}
+
+export {
+ up,
+ down
+}
diff --git a/server/lib/auth/oauth.ts b/server/lib/auth/oauth.ts
index fa1887315..35b05ec5a 100644
--- a/server/lib/auth/oauth.ts
+++ b/server/lib/auth/oauth.ts
@@ -9,11 +9,23 @@ import OAuth2Server, {
UnsupportedGrantTypeError
} from '@node-oauth/oauth2-server'
import { randomBytesPromise } from '@server/helpers/core-utils'
+import { isOTPValid } from '@server/helpers/otp'
import { MOAuthClient } from '@server/types/models'
import { sha1 } from '@shared/extra-utils'
-import { OAUTH_LIFETIME } from '../../initializers/constants'
+import { HttpStatusCode } from '@shared/models'
+import { OAUTH_LIFETIME, OTP } from '../../initializers/constants'
import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
+class MissingTwoFactorError extends Error {
+ code = HttpStatusCode.UNAUTHORIZED_401
+ name = 'missing_two_factor'
+}
+
+class InvalidTwoFactorError extends Error {
+ code = HttpStatusCode.BAD_REQUEST_400
+ name = 'invalid_two_factor'
+}
+
/**
*
* Reimplement some functions of OAuth2Server to inject external auth methods
@@ -94,6 +106,9 @@ function handleOAuthAuthenticate (
}
export {
+ MissingTwoFactorError,
+ InvalidTwoFactorError,
+
handleOAuthToken,
handleOAuthAuthenticate
}
@@ -118,6 +133,16 @@ async function handlePasswordGrant (options: {
const user = await getUser(request.body.username, request.body.password, bypassLogin)
if (!user) throw new InvalidGrantError('Invalid grant: user credentials are invalid')
+ if (user.otpSecret) {
+ if (!request.headers[OTP.HEADER_NAME]) {
+ throw new MissingTwoFactorError('Missing two factor header')
+ }
+
+ if (await isOTPValid({ encryptedSecret: user.otpSecret, token: request.headers[OTP.HEADER_NAME] }) !== true) {
+ throw new InvalidTwoFactorError('Invalid two factor header')
+ }
+ }
+
const token = await buildToken()
return saveToken(token, client, user, { bypassLogin })
diff --git a/server/lib/redis.ts b/server/lib/redis.ts
index 9b3c72300..b7523492a 100644
--- a/server/lib/redis.ts
+++ b/server/lib/redis.ts
@@ -9,6 +9,7 @@ import {
CONTACT_FORM_LIFETIME,
RESUMABLE_UPLOAD_SESSION_LIFETIME,
TRACKER_RATE_LIMITS,
+ TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
USER_EMAIL_VERIFY_LIFETIME,
USER_PASSWORD_CREATE_LIFETIME,
USER_PASSWORD_RESET_LIFETIME,
@@ -108,10 +109,24 @@ class Redis {
return this.removeValue(this.generateResetPasswordKey(userId))
}
- async getResetPasswordLink (userId: number) {
+ async getResetPasswordVerificationString (userId: number) {
return this.getValue(this.generateResetPasswordKey(userId))
}
+ /* ************ Two factor auth request ************ */
+
+ async setTwoFactorRequest (userId: number, otpSecret: string) {
+ const requestToken = await generateRandomString(32)
+
+ await this.setValue(this.generateTwoFactorRequestKey(userId, requestToken), otpSecret, TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME)
+
+ return requestToken
+ }
+
+ async getTwoFactorRequestToken (userId: number, requestToken: string) {
+ return this.getValue(this.generateTwoFactorRequestKey(userId, requestToken))
+ }
+
/* ************ Email verification ************ */
async setVerifyEmailVerificationString (userId: number) {
@@ -342,6 +357,10 @@ class Redis {
return 'reset-password-' + userId
}
+ private generateTwoFactorRequestKey (userId: number, token: string) {
+ return 'two-factor-request-' + userId + '-' + token
+ }
+
private generateVerifyEmailKey (userId: number) {
return 'verify-email-' + userId
}
@@ -391,8 +410,8 @@ class Redis {
return JSON.parse(value)
}
- private setObject (key: string, value: { [ id: string ]: number | string }) {
- return this.setValue(key, JSON.stringify(value))
+ private setObject (key: string, value: { [ id: string ]: number | string }, expirationMilliseconds?: number) {
+ return this.setValue(key, JSON.stringify(value), expirationMilliseconds)
}
private async setValue (key: string, value: string, expirationMilliseconds?: number) {
diff --git a/server/middlewares/validators/shared/index.ts b/server/middlewares/validators/shared/index.ts
index bbd03b248..de98cd442 100644
--- a/server/middlewares/validators/shared/index.ts
+++ b/server/middlewares/validators/shared/index.ts
@@ -1,5 +1,6 @@
export * from './abuses'
export * from './accounts'
+export * from './users'
export * from './utils'
export * from './video-blacklists'
export * from './video-captions'
diff --git a/server/middlewares/validators/shared/users.ts b/server/middlewares/validators/shared/users.ts
new file mode 100644
index 000000000..fbaa7db0e
--- /dev/null
+++ b/server/middlewares/validators/shared/users.ts
@@ -0,0 +1,62 @@
+import express from 'express'
+import { ActorModel } from '@server/models/actor/actor'
+import { UserModel } from '@server/models/user/user'
+import { MUserDefault } from '@server/types/models'
+import { HttpStatusCode } from '@shared/models'
+
+function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
+ const id = parseInt(idArg + '', 10)
+ return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res)
+}
+
+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) {
+ const user = await UserModel.loadByUsernameOrEmail(username, email)
+
+ if (user) {
+ res.fail({
+ status: HttpStatusCode.CONFLICT_409,
+ message: 'User with this username or email already exists.'
+ })
+ return false
+ }
+
+ const actor = await ActorModel.loadLocalByName(username)
+ if (actor) {
+ res.fail({
+ status: HttpStatusCode.CONFLICT_409,
+ message: 'Another actor (account/channel) with this name on this instance already exists or has already existed.'
+ })
+ return false
+ }
+
+ return true
+}
+
+async function checkUserExist (finder: () => Promise, res: express.Response, abortResponse = true) {
+ const user = await finder()
+
+ if (!user) {
+ if (abortResponse === true) {
+ res.fail({
+ status: HttpStatusCode.NOT_FOUND_404,
+ message: 'User not found'
+ })
+ }
+
+ return false
+ }
+
+ res.locals.user = user
+ return true
+}
+
+export {
+ checkUserIdExist,
+ checkUserEmailExist,
+ checkUserNameOrEmailDoesNotAlreadyExist,
+ checkUserExist
+}
diff --git a/server/middlewares/validators/two-factor.ts b/server/middlewares/validators/two-factor.ts
new file mode 100644
index 000000000..106b579b5
--- /dev/null
+++ b/server/middlewares/validators/two-factor.ts
@@ -0,0 +1,81 @@
+import express from 'express'
+import { body, param } from 'express-validator'
+import { HttpStatusCode, UserRight } from '@shared/models'
+import { exists, isIdValid } from '../../helpers/custom-validators/misc'
+import { areValidationErrors, checkUserIdExist } from './shared'
+
+const requestOrConfirmTwoFactorValidator = [
+ param('id').custom(isIdValid),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (areValidationErrors(req, res)) return
+
+ if (!await checkCanEnableOrDisableTwoFactor(req.params.id, res)) return
+
+ if (res.locals.user.otpSecret) {
+ return res.fail({
+ status: HttpStatusCode.BAD_REQUEST_400,
+ message: `Two factor is already enabled.`
+ })
+ }
+
+ return next()
+ }
+]
+
+const confirmTwoFactorValidator = [
+ body('requestToken').custom(exists),
+ body('otpToken').custom(exists),
+
+ (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (areValidationErrors(req, res)) return
+
+ return next()
+ }
+]
+
+const disableTwoFactorValidator = [
+ param('id').custom(isIdValid),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (areValidationErrors(req, res)) return
+
+ if (!await checkCanEnableOrDisableTwoFactor(req.params.id, res)) return
+
+ if (!res.locals.user.otpSecret) {
+ return res.fail({
+ status: HttpStatusCode.BAD_REQUEST_400,
+ message: `Two factor is already disabled.`
+ })
+ }
+
+ return next()
+ }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+ requestOrConfirmTwoFactorValidator,
+ confirmTwoFactorValidator,
+ disableTwoFactorValidator
+}
+
+// ---------------------------------------------------------------------------
+
+async function checkCanEnableOrDisableTwoFactor (userId: number | string, res: express.Response) {
+ const authUser = res.locals.oauth.token.user
+
+ if (!await checkUserIdExist(userId, res)) return
+
+ if (res.locals.user.id !== authUser.id && authUser.hasRight(UserRight.MANAGE_USERS) !== true) {
+ res.fail({
+ status: HttpStatusCode.FORBIDDEN_403,
+ message: `User ${authUser.username} does not have right to change two factor setting of this user.`
+ })
+
+ return false
+ }
+
+ return true
+}
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index eb693318f..055af3b64 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -1,9 +1,8 @@
import express from 'express'
import { body, param, query } from 'express-validator'
import { Hooks } from '@server/lib/plugins/hooks'
-import { MUserDefault } from '@server/types/models'
import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/models'
-import { isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
+import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
import {
isUserAdminFlagsValid,
@@ -30,8 +29,15 @@ import { isThemeRegistered } from '../../lib/plugins/theme-utils'
import { Redis } from '../../lib/redis'
import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup'
import { ActorModel } from '../../models/actor/actor'
-import { UserModel } from '../../models/user/user'
-import { areValidationErrors, doesVideoChannelIdExist, doesVideoExist, isValidVideoIdParam } from './shared'
+import {
+ areValidationErrors,
+ checkUserEmailExist,
+ checkUserIdExist,
+ checkUserNameOrEmailDoesNotAlreadyExist,
+ doesVideoChannelIdExist,
+ doesVideoExist,
+ isValidVideoIdParam
+} from './shared'
const usersListValidator = [
query('blocked')
@@ -435,7 +441,7 @@ const usersResetPasswordValidator = [
if (!await checkUserIdExist(req.params.id, res)) return
const user = res.locals.user
- const redisVerificationString = await Redis.Instance.getResetPasswordLink(user.id)
+ const redisVerificationString = await Redis.Instance.getResetPasswordVerificationString(user.id)
if (redisVerificationString !== req.body.verificationString) {
return res.fail({
@@ -500,6 +506,41 @@ const usersVerifyEmailValidator = [
}
]
+const usersCheckCurrentPasswordFactory = (targetUserIdGetter: (req: express.Request) => number | string) => {
+ return [
+ body('currentPassword').optional().custom(exists),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (areValidationErrors(req, res)) return
+
+ const user = res.locals.oauth.token.User
+ const isAdminOrModerator = user.role === UserRole.ADMINISTRATOR || user.role === UserRole.MODERATOR
+ const targetUserId = parseInt(targetUserIdGetter(req) + '')
+
+ // Admin/moderator action on another user, skip the password check
+ if (isAdminOrModerator && targetUserId !== user.id) {
+ return next()
+ }
+
+ if (!req.body.currentPassword) {
+ return res.fail({
+ status: HttpStatusCode.BAD_REQUEST_400,
+ message: 'currentPassword is missing'
+ })
+ }
+
+ if (await user.isPasswordMatch(req.body.currentPassword) !== true) {
+ return res.fail({
+ status: HttpStatusCode.FORBIDDEN_403,
+ message: 'currentPassword is invalid.'
+ })
+ }
+
+ return next()
+ }
+ ]
+}
+
const userAutocompleteValidator = [
param('search')
.isString()
@@ -567,6 +608,7 @@ export {
usersUpdateValidator,
usersUpdateMeValidator,
usersVideoRatingValidator,
+ usersCheckCurrentPasswordFactory,
ensureUserRegistrationAllowed,
ensureUserRegistrationAllowedForIP,
usersGetValidator,
@@ -580,55 +622,3 @@ export {
ensureCanModerateUser,
ensureCanManageChannelOrAccount
}
-
-// ---------------------------------------------------------------------------
-
-function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
- const id = parseInt(idArg + '', 10)
- return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res)
-}
-
-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) {
- const user = await UserModel.loadByUsernameOrEmail(username, email)
-
- if (user) {
- res.fail({
- status: HttpStatusCode.CONFLICT_409,
- message: 'User with this username or email already exists.'
- })
- return false
- }
-
- const actor = await ActorModel.loadLocalByName(username)
- if (actor) {
- res.fail({
- status: HttpStatusCode.CONFLICT_409,
- message: 'Another actor (account/channel) with this name on this instance already exists or has already existed.'
- })
- return false
- }
-
- return true
-}
-
-async function checkUserExist (finder: () => Promise, res: express.Response, abortResponse = true) {
- const user = await finder()
-
- if (!user) {
- if (abortResponse === true) {
- res.fail({
- status: HttpStatusCode.NOT_FOUND_404,
- message: 'User not found'
- })
- }
-
- return false
- }
-
- res.locals.user = user
- return true
-}
diff --git a/server/models/user/user.ts b/server/models/user/user.ts
index 1a7c84390..34329580b 100644
--- a/server/models/user/user.ts
+++ b/server/models/user/user.ts
@@ -403,6 +403,11 @@ export class UserModel extends Model>> {
@Column
lastLoginDate: Date
+ @AllowNull(true)
+ @Default(null)
+ @Column
+ otpSecret: string
+
@CreatedAt
createdAt: Date
@@ -935,7 +940,9 @@ export class UserModel extends Model>> {
pluginAuth: this.pluginAuth,
- lastLoginDate: this.lastLoginDate
+ lastLoginDate: this.lastLoginDate,
+
+ twoFactorEnabled: !!this.otpSecret
}
if (parameters.withAdminFlags) {
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index cd7a38459..33dc8fb76 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -2,6 +2,7 @@ import './abuses'
import './accounts'
import './blocklist'
import './bulk'
+import './channel-import-videos'
import './config'
import './contact-form'
import './custom-pages'
@@ -17,6 +18,7 @@ import './redundancy'
import './search'
import './services'
import './transcoding'
+import './two-factor'
import './upload-quota'
import './user-notifications'
import './user-subscriptions'
@@ -24,12 +26,11 @@ import './users-admin'
import './users'
import './video-blacklist'
import './video-captions'
+import './video-channel-syncs'
import './video-channels'
import './video-comments'
import './video-files'
import './video-imports'
-import './video-channel-syncs'
-import './channel-import-videos'
import './video-playlists'
import './video-source'
import './video-studio'
diff --git a/server/tests/api/check-params/two-factor.ts b/server/tests/api/check-params/two-factor.ts
new file mode 100644
index 000000000..f8365f1b5
--- /dev/null
+++ b/server/tests/api/check-params/two-factor.ts
@@ -0,0 +1,288 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { HttpStatusCode } from '@shared/models'
+import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, TwoFactorCommand } from '@shared/server-commands'
+
+describe('Test two factor API validators', function () {
+ let server: PeerTubeServer
+
+ let rootId: number
+ let rootPassword: string
+ let rootRequestToken: string
+ let rootOTPToken: string
+
+ let userId: number
+ let userToken = ''
+ let userPassword: string
+ let userRequestToken: string
+ let userOTPToken: string
+
+ // ---------------------------------------------------------------
+
+ before(async function () {
+ this.timeout(30000)
+
+ {
+ server = await createSingleServer(1)
+ await setAccessTokensToServers([ server ])
+ }
+
+ {
+ const result = await server.users.generate('user1')
+ userToken = result.token
+ userId = result.userId
+ userPassword = result.password
+ }
+
+ {
+ const { id } = await server.users.getMyInfo()
+ rootId = id
+ rootPassword = server.store.user.password
+ }
+ })
+
+ describe('When requesting two factor', function () {
+
+ it('Should fail with an unknown user id', async function () {
+ await server.twoFactor.request({ userId: 42, currentPassword: rootPassword, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+ })
+
+ it('Should fail with an invalid user id', async function () {
+ await server.twoFactor.request({
+ userId: 'invalid' as any,
+ currentPassword: rootPassword,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400
+ })
+ })
+
+ it('Should fail to request another user two factor without the appropriate rights', async function () {
+ await server.twoFactor.request({
+ userId: rootId,
+ token: userToken,
+ currentPassword: userPassword,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ })
+
+ it('Should succeed to request another user two factor with the appropriate rights', async function () {
+ await server.twoFactor.request({ userId, currentPassword: rootPassword })
+ })
+
+ it('Should fail to request two factor without a password', async function () {
+ await server.twoFactor.request({
+ userId,
+ token: userToken,
+ currentPassword: undefined,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400
+ })
+ })
+
+ it('Should fail to request two factor with an incorrect password', async function () {
+ await server.twoFactor.request({
+ userId,
+ token: userToken,
+ currentPassword: rootPassword,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ })
+
+ it('Should succeed to request two factor without a password when targeting a remote user with an admin account', async function () {
+ await server.twoFactor.request({ userId })
+ })
+
+ it('Should fail to request two factor without a password when targeting myself with an admin account', async function () {
+ await server.twoFactor.request({ userId: rootId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+ await server.twoFactor.request({ userId: rootId, currentPassword: 'bad', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+ })
+
+ it('Should succeed to request my two factor auth', async function () {
+ {
+ const { otpRequest } = await server.twoFactor.request({ userId, token: userToken, currentPassword: userPassword })
+ userRequestToken = otpRequest.requestToken
+ userOTPToken = TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate()
+ }
+
+ {
+ const { otpRequest } = await server.twoFactor.request({ userId: rootId, currentPassword: rootPassword })
+ rootRequestToken = otpRequest.requestToken
+ rootOTPToken = TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate()
+ }
+ })
+ })
+
+ describe('When confirming two factor request', function () {
+
+ it('Should fail with an unknown user id', async function () {
+ await server.twoFactor.confirmRequest({
+ userId: 42,
+ requestToken: rootRequestToken,
+ otpToken: rootOTPToken,
+ expectedStatus: HttpStatusCode.NOT_FOUND_404
+ })
+ })
+
+ it('Should fail with an invalid user id', async function () {
+ await server.twoFactor.confirmRequest({
+ userId: 'invalid' as any,
+ requestToken: rootRequestToken,
+ otpToken: rootOTPToken,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400
+ })
+ })
+
+ it('Should fail to confirm another user two factor request without the appropriate rights', async function () {
+ await server.twoFactor.confirmRequest({
+ userId: rootId,
+ token: userToken,
+ requestToken: rootRequestToken,
+ otpToken: rootOTPToken,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ })
+
+ it('Should fail without request token', async function () {
+ await server.twoFactor.confirmRequest({
+ userId,
+ requestToken: undefined,
+ otpToken: userOTPToken,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400
+ })
+ })
+
+ it('Should fail with an invalid request token', async function () {
+ await server.twoFactor.confirmRequest({
+ userId,
+ requestToken: 'toto',
+ otpToken: userOTPToken,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ })
+
+ it('Should fail with request token of another user', async function () {
+ await server.twoFactor.confirmRequest({
+ userId,
+ requestToken: rootRequestToken,
+ otpToken: userOTPToken,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ })
+
+ it('Should fail without an otp token', async function () {
+ await server.twoFactor.confirmRequest({
+ userId,
+ requestToken: userRequestToken,
+ otpToken: undefined,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400
+ })
+ })
+
+ it('Should fail with a bad otp token', async function () {
+ await server.twoFactor.confirmRequest({
+ userId,
+ requestToken: userRequestToken,
+ otpToken: '123456',
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ })
+
+ it('Should succeed to confirm another user two factor request with the appropriate rights', async function () {
+ await server.twoFactor.confirmRequest({
+ userId,
+ requestToken: userRequestToken,
+ otpToken: userOTPToken
+ })
+
+ // Reinit
+ await server.twoFactor.disable({ userId, currentPassword: rootPassword })
+ })
+
+ it('Should succeed to confirm my two factor request', async function () {
+ await server.twoFactor.confirmRequest({
+ userId,
+ token: userToken,
+ requestToken: userRequestToken,
+ otpToken: userOTPToken
+ })
+ })
+
+ it('Should fail to confirm again two factor request', async function () {
+ await server.twoFactor.confirmRequest({
+ userId,
+ token: userToken,
+ requestToken: userRequestToken,
+ otpToken: userOTPToken,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400
+ })
+ })
+ })
+
+ describe('When disabling two factor', function () {
+
+ it('Should fail with an unknown user id', async function () {
+ await server.twoFactor.disable({
+ userId: 42,
+ currentPassword: rootPassword,
+ expectedStatus: HttpStatusCode.NOT_FOUND_404
+ })
+ })
+
+ it('Should fail with an invalid user id', async function () {
+ await server.twoFactor.disable({
+ userId: 'invalid' as any,
+ currentPassword: rootPassword,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400
+ })
+ })
+
+ it('Should fail to disable another user two factor without the appropriate rights', async function () {
+ await server.twoFactor.disable({
+ userId: rootId,
+ token: userToken,
+ currentPassword: userPassword,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ })
+
+ it('Should fail to disable two factor with an incorrect password', async function () {
+ await server.twoFactor.disable({
+ userId,
+ token: userToken,
+ currentPassword: rootPassword,
+ expectedStatus: HttpStatusCode.FORBIDDEN_403
+ })
+ })
+
+ it('Should succeed to disable two factor without a password when targeting a remote user with an admin account', async function () {
+ await server.twoFactor.disable({ userId })
+ await server.twoFactor.requestAndConfirm({ userId })
+ })
+
+ it('Should fail to disable two factor without a password when targeting myself with an admin account', async function () {
+ await server.twoFactor.disable({ userId: rootId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+ await server.twoFactor.disable({ userId: rootId, currentPassword: 'bad', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+ })
+
+ it('Should succeed to disable another user two factor with the appropriate rights', async function () {
+ await server.twoFactor.disable({ userId, currentPassword: rootPassword })
+
+ await server.twoFactor.requestAndConfirm({ userId })
+ })
+
+ it('Should succeed to update my two factor auth', async function () {
+ await server.twoFactor.disable({ userId, token: userToken, currentPassword: userPassword })
+ })
+
+ it('Should fail to disable again two factor', async function () {
+ await server.twoFactor.disable({
+ userId,
+ token: userToken,
+ currentPassword: userPassword,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400
+ })
+ })
+ })
+
+ after(async function () {
+ await cleanupTests([ server ])
+ })
+})
diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts
index c65152c6f..643f1a531 100644
--- a/server/tests/api/users/index.ts
+++ b/server/tests/api/users/index.ts
@@ -1,3 +1,4 @@
+import './two-factor'
import './user-subscriptions'
import './user-videos'
import './users'
diff --git a/server/tests/api/users/two-factor.ts b/server/tests/api/users/two-factor.ts
new file mode 100644
index 000000000..0dcab9e17
--- /dev/null
+++ b/server/tests/api/users/two-factor.ts
@@ -0,0 +1,200 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { expectStartWith } from '@server/tests/shared'
+import { HttpStatusCode } from '@shared/models'
+import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, TwoFactorCommand } from '@shared/server-commands'
+
+async function login (options: {
+ server: PeerTubeServer
+ username: string
+ password: string
+ otpToken?: string
+ expectedStatus?: HttpStatusCode
+}) {
+ const { server, username, password, otpToken, expectedStatus } = options
+
+ const user = { username, password }
+ const { res, body: { access_token: token } } = await server.login.loginAndGetResponse({ user, otpToken, expectedStatus })
+
+ return { res, token }
+}
+
+describe('Test users', function () {
+ let server: PeerTubeServer
+ let otpSecret: string
+ let requestToken: string
+
+ const userUsername = 'user1'
+ let userId: number
+ let userPassword: string
+ let userToken: string
+
+ before(async function () {
+ this.timeout(30000)
+
+ server = await createSingleServer(1)
+
+ await setAccessTokensToServers([ server ])
+ const res = await server.users.generate(userUsername)
+ userId = res.userId
+ userPassword = res.password
+ userToken = res.token
+ })
+
+ it('Should not add the header on login if two factor is not enabled', async function () {
+ const { res, token } = await login({ server, username: userUsername, password: userPassword })
+
+ expect(res.header['x-peertube-otp']).to.not.exist
+
+ await server.users.getMyInfo({ token })
+ })
+
+ it('Should request two factor and get the secret and uri', async function () {
+ const { otpRequest } = await server.twoFactor.request({ userId, token: userToken, currentPassword: userPassword })
+
+ expect(otpRequest.requestToken).to.exist
+
+ expect(otpRequest.secret).to.exist
+ expect(otpRequest.secret).to.have.lengthOf(32)
+
+ expect(otpRequest.uri).to.exist
+ expectStartWith(otpRequest.uri, 'otpauth://')
+ expect(otpRequest.uri).to.include(otpRequest.secret)
+
+ requestToken = otpRequest.requestToken
+ otpSecret = otpRequest.secret
+ })
+
+ it('Should not have two factor confirmed yet', async function () {
+ const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken })
+ expect(twoFactorEnabled).to.be.false
+ })
+
+ it('Should confirm two factor', async function () {
+ await server.twoFactor.confirmRequest({
+ userId,
+ token: userToken,
+ otpToken: TwoFactorCommand.buildOTP({ secret: otpSecret }).generate(),
+ requestToken
+ })
+ })
+
+ it('Should not add the header on login if two factor is enabled and password is incorrect', async function () {
+ const { res, token } = await login({ server, username: userUsername, password: 'fake', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+ expect(res.header['x-peertube-otp']).to.not.exist
+ expect(token).to.not.exist
+ })
+
+ it('Should add the header on login if two factor is enabled and password is correct', async function () {
+ const { res, token } = await login({
+ server,
+ username: userUsername,
+ password: userPassword,
+ expectedStatus: HttpStatusCode.UNAUTHORIZED_401
+ })
+
+ expect(res.header['x-peertube-otp']).to.exist
+ expect(token).to.not.exist
+
+ await server.users.getMyInfo({ token })
+ })
+
+ it('Should not login with correct password and incorrect otp secret', async function () {
+ const otp = TwoFactorCommand.buildOTP({ secret: 'a'.repeat(32) })
+
+ const { res, token } = await login({
+ server,
+ username: userUsername,
+ password: userPassword,
+ otpToken: otp.generate(),
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400
+ })
+
+ expect(res.header['x-peertube-otp']).to.not.exist
+ expect(token).to.not.exist
+ })
+
+ it('Should not login with correct password and incorrect otp code', async function () {
+ const { res, token } = await login({
+ server,
+ username: userUsername,
+ password: userPassword,
+ otpToken: '123456',
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400
+ })
+
+ expect(res.header['x-peertube-otp']).to.not.exist
+ expect(token).to.not.exist
+ })
+
+ it('Should not login with incorrect password and correct otp code', async function () {
+ const otpToken = TwoFactorCommand.buildOTP({ secret: otpSecret }).generate()
+
+ const { res, token } = await login({
+ server,
+ username: userUsername,
+ password: 'fake',
+ otpToken,
+ expectedStatus: HttpStatusCode.BAD_REQUEST_400
+ })
+
+ expect(res.header['x-peertube-otp']).to.not.exist
+ expect(token).to.not.exist
+ })
+
+ it('Should correctly login with correct password and otp code', async function () {
+ const otpToken = TwoFactorCommand.buildOTP({ secret: otpSecret }).generate()
+
+ const { res, token } = await login({ server, username: userUsername, password: userPassword, otpToken })
+
+ expect(res.header['x-peertube-otp']).to.not.exist
+ expect(token).to.exist
+
+ await server.users.getMyInfo({ token })
+ })
+
+ it('Should have two factor enabled when getting my info', async function () {
+ const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken })
+ expect(twoFactorEnabled).to.be.true
+ })
+
+ it('Should disable two factor and be able to login without otp token', async function () {
+ await server.twoFactor.disable({ userId, token: userToken, currentPassword: userPassword })
+
+ const { res, token } = await login({ server, username: userUsername, password: userPassword })
+ expect(res.header['x-peertube-otp']).to.not.exist
+
+ await server.users.getMyInfo({ token })
+ })
+
+ it('Should have two factor disabled when getting my info', async function () {
+ const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken })
+ expect(twoFactorEnabled).to.be.false
+ })
+
+ it('Should enable two factor auth without password from an admin', async function () {
+ const { otpRequest } = await server.twoFactor.request({ userId })
+
+ await server.twoFactor.confirmRequest({
+ userId,
+ otpToken: TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate(),
+ requestToken: otpRequest.requestToken
+ })
+
+ const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken })
+ expect(twoFactorEnabled).to.be.true
+ })
+
+ it('Should disable two factor auth without password from an admin', async function () {
+ await server.twoFactor.disable({ userId })
+
+ const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken })
+ expect(twoFactorEnabled).to.be.false
+ })
+
+ after(async function () {
+ await cleanupTests([ server ])
+ })
+})
diff --git a/server/tests/helpers/crypto.ts b/server/tests/helpers/crypto.ts
new file mode 100644
index 000000000..b508c715b
--- /dev/null
+++ b/server/tests/helpers/crypto.ts
@@ -0,0 +1,33 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { decrypt, encrypt } from '@server/helpers/peertube-crypto'
+
+describe('Encrypt/Descrypt', function () {
+
+ it('Should encrypt and decrypt the string', async function () {
+ const secret = 'my_secret'
+ const str = 'my super string'
+
+ const encrypted = await encrypt(str, secret)
+ const decrypted = await decrypt(encrypted, secret)
+
+ expect(str).to.equal(decrypted)
+ })
+
+ it('Should not decrypt without the same secret', async function () {
+ const str = 'my super string'
+
+ const encrypted = await encrypt(str, 'my_secret')
+
+ let error = false
+
+ try {
+ await decrypt(encrypted, 'my_sicret')
+ } catch (err) {
+ error = true
+ }
+
+ expect(error).to.be.true
+ })
+})
diff --git a/server/tests/helpers/index.ts b/server/tests/helpers/index.ts
index 951208842..42d644c40 100644
--- a/server/tests/helpers/index.ts
+++ b/server/tests/helpers/index.ts
@@ -1,6 +1,7 @@
-import './image'
+import './crypto'
import './core-utils'
import './dns'
+import './dns'
import './comment-model'
import './markdown'
import './request'
diff --git a/shared/models/users/index.ts b/shared/models/users/index.ts
index b25978587..32f7a441c 100644
--- a/shared/models/users/index.ts
+++ b/shared/models/users/index.ts
@@ -1,3 +1,4 @@
+export * from './two-factor-enable-result.model'
export * from './user-create-result.model'
export * from './user-create.model'
export * from './user-flag.model'
diff --git a/shared/models/users/two-factor-enable-result.model.ts b/shared/models/users/two-factor-enable-result.model.ts
new file mode 100644
index 000000000..1fc801f0a
--- /dev/null
+++ b/shared/models/users/two-factor-enable-result.model.ts
@@ -0,0 +1,7 @@
+export interface TwoFactorEnableResult {
+ otpRequest: {
+ requestToken: string
+ secret: string
+ uri: string
+ }
+}
diff --git a/shared/models/users/user.model.ts b/shared/models/users/user.model.ts
index 63c5c8a92..7b6494ff8 100644
--- a/shared/models/users/user.model.ts
+++ b/shared/models/users/user.model.ts
@@ -62,6 +62,8 @@ export interface User {
pluginAuth: string | null
lastLoginDate: Date | null
+
+ twoFactorEnabled: boolean
}
export interface MyUserSpecialPlaylist {
diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts
index a8f8c1d84..7096faf21 100644
--- a/shared/server-commands/server/server.ts
+++ b/shared/server-commands/server/server.ts
@@ -13,7 +13,15 @@ import { AbusesCommand } from '../moderation'
import { OverviewsCommand } from '../overviews'
import { SearchCommand } from '../search'
import { SocketIOCommand } from '../socket'
-import { AccountsCommand, BlocklistCommand, LoginCommand, NotificationsCommand, SubscriptionsCommand, UsersCommand } from '../users'
+import {
+ AccountsCommand,
+ BlocklistCommand,
+ LoginCommand,
+ NotificationsCommand,
+ SubscriptionsCommand,
+ TwoFactorCommand,
+ UsersCommand
+} from '../users'
import {
BlacklistCommand,
CaptionsCommand,
@@ -136,6 +144,7 @@ export class PeerTubeServer {
videos?: VideosCommand
videoStats?: VideoStatsCommand
views?: ViewsCommand
+ twoFactor?: TwoFactorCommand
constructor (options: { serverNumber: number } | { url: string }) {
if ((options as any).url) {
@@ -417,5 +426,6 @@ export class PeerTubeServer {
this.videoStudio = new VideoStudioCommand(this)
this.videoStats = new VideoStatsCommand(this)
this.views = new ViewsCommand(this)
+ this.twoFactor = new TwoFactorCommand(this)
}
}
diff --git a/shared/server-commands/users/index.ts b/shared/server-commands/users/index.ts
index f6f93b4d2..1afc02dc1 100644
--- a/shared/server-commands/users/index.ts
+++ b/shared/server-commands/users/index.ts
@@ -5,4 +5,5 @@ export * from './login'
export * from './login-command'
export * from './notifications-command'
export * from './subscriptions-command'
+export * from './two-factor-command'
export * from './users-command'
diff --git a/shared/server-commands/users/login-command.ts b/shared/server-commands/users/login-command.ts
index 54070e426..f2fc6d1c5 100644
--- a/shared/server-commands/users/login-command.ts
+++ b/shared/server-commands/users/login-command.ts
@@ -2,34 +2,27 @@ import { HttpStatusCode, PeerTubeProblemDocument } from '@shared/models'
import { unwrapBody } from '../requests'
import { AbstractCommand, OverrideCommandOptions } from '../shared'
+type LoginOptions = OverrideCommandOptions & {
+ client?: { id?: string, secret?: string }
+ user?: { username: string, password?: string }
+ otpToken?: string
+}
+
export class LoginCommand extends AbstractCommand {
- login (options: OverrideCommandOptions & {
- client?: { id?: string, secret?: string }
- user?: { username: string, password?: string }
- } = {}) {
- const { client = this.server.store.client, user = this.server.store.user } = options
- const path = '/api/v1/users/token'
+ async login (options: LoginOptions = {}) {
+ const res = await this._login(options)
- const body = {
- client_id: client.id,
- client_secret: client.secret,
- username: user.username,
- password: user.password ?? 'password',
- response_type: 'code',
- grant_type: 'password',
- scope: 'upload'
+ return this.unwrapLoginBody(res.body)
+ }
+
+ async loginAndGetResponse (options: LoginOptions = {}) {
+ const res = await this._login(options)
+
+ return {
+ res,
+ body: this.unwrapLoginBody(res.body)
}
-
- return unwrapBody<{ access_token: string, refresh_token: string } & PeerTubeProblemDocument>(this.postBodyRequest({
- ...options,
-
- path,
- requestType: 'form',
- fields: body,
- implicitToken: false,
- defaultExpectedStatus: HttpStatusCode.OK_200
- }))
}
getAccessToken (arg1?: { username: string, password?: string }): Promise
@@ -129,4 +122,38 @@ export class LoginCommand extends AbstractCommand {
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
+
+ private _login (options: LoginOptions) {
+ const { client = this.server.store.client, user = this.server.store.user, otpToken } = options
+ const path = '/api/v1/users/token'
+
+ const body = {
+ client_id: client.id,
+ client_secret: client.secret,
+ username: user.username,
+ password: user.password ?? 'password',
+ response_type: 'code',
+ grant_type: 'password',
+ scope: 'upload'
+ }
+
+ const headers = otpToken
+ ? { 'x-peertube-otp': otpToken }
+ : {}
+
+ return this.postBodyRequest({
+ ...options,
+
+ path,
+ headers,
+ requestType: 'form',
+ fields: body,
+ implicitToken: false,
+ defaultExpectedStatus: HttpStatusCode.OK_200
+ })
+ }
+
+ private unwrapLoginBody (body: any) {
+ return body as { access_token: string, refresh_token: string } & PeerTubeProblemDocument
+ }
}
diff --git a/shared/server-commands/users/two-factor-command.ts b/shared/server-commands/users/two-factor-command.ts
new file mode 100644
index 000000000..5542acfda
--- /dev/null
+++ b/shared/server-commands/users/two-factor-command.ts
@@ -0,0 +1,92 @@
+import { TOTP } from 'otpauth'
+import { HttpStatusCode, TwoFactorEnableResult } from '@shared/models'
+import { unwrapBody } from '../requests'
+import { AbstractCommand, OverrideCommandOptions } from '../shared'
+
+export class TwoFactorCommand extends AbstractCommand {
+
+ static buildOTP (options: {
+ secret: string
+ }) {
+ const { secret } = options
+
+ return new TOTP({
+ issuer: 'PeerTube',
+ algorithm: 'SHA1',
+ digits: 6,
+ period: 30,
+ secret
+ })
+ }
+
+ request (options: OverrideCommandOptions & {
+ userId: number
+ currentPassword?: string
+ }) {
+ const { currentPassword, userId } = options
+
+ const path = '/api/v1/users/' + userId + '/two-factor/request'
+
+ return unwrapBody(this.postBodyRequest({
+ ...options,
+
+ path,
+ fields: { currentPassword },
+ implicitToken: true,
+ defaultExpectedStatus: HttpStatusCode.OK_200
+ }))
+ }
+
+ confirmRequest (options: OverrideCommandOptions & {
+ userId: number
+ requestToken: string
+ otpToken: string
+ }) {
+ const { userId, requestToken, otpToken } = options
+
+ const path = '/api/v1/users/' + userId + '/two-factor/confirm-request'
+
+ return this.postBodyRequest({
+ ...options,
+
+ path,
+ fields: { requestToken, otpToken },
+ implicitToken: true,
+ defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+ })
+ }
+
+ disable (options: OverrideCommandOptions & {
+ userId: number
+ currentPassword?: string
+ }) {
+ const { userId, currentPassword } = options
+ const path = '/api/v1/users/' + userId + '/two-factor/disable'
+
+ return this.postBodyRequest({
+ ...options,
+
+ path,
+ fields: { currentPassword },
+ implicitToken: true,
+ defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+ })
+ }
+
+ async requestAndConfirm (options: OverrideCommandOptions & {
+ userId: number
+ currentPassword?: string
+ }) {
+ const { userId, currentPassword } = options
+
+ const { otpRequest } = await this.request({ userId, currentPassword })
+
+ await this.confirmRequest({
+ userId,
+ requestToken: otpRequest.requestToken,
+ otpToken: TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate()
+ })
+
+ return otpRequest
+ }
+}
diff --git a/shared/server-commands/users/users-command.ts b/shared/server-commands/users/users-command.ts
index e7d021059..811b9685b 100644
--- a/shared/server-commands/users/users-command.ts
+++ b/shared/server-commands/users/users-command.ts
@@ -202,7 +202,8 @@ export class UsersCommand extends AbstractCommand {
token,
userId: user.id,
userChannelId: me.videoChannels[0].id,
- userChannelName: me.videoChannels[0].name
+ userChannelName: me.videoChannels[0].name,
+ password
}
}
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index c62310b76..2fb154dbd 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -1126,6 +1126,97 @@ paths:
'404':
description: user not found
+ /users/{id}/two-factor/request:
+ post:
+ summary: Request two factor auth
+ operationId: requestTwoFactor
+ description: Request two factor authentication for a user
+ tags:
+ - Users
+ parameters:
+ - $ref: '#/components/parameters/id'
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ currentPassword:
+ type: string
+ description: Password of the currently authenticated user
+ responses:
+ '200':
+ description: successful operation
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/RequestTwoFactorResponse'
+ '403':
+ description: invalid password
+ '404':
+ description: user not found
+
+ /users/{id}/two-factor/confirm-request:
+ post:
+ summary: Confirm two factor auth
+ operationId: confirmTwoFactorRequest
+ description: Confirm a two factor authentication request
+ tags:
+ - Users
+ parameters:
+ - $ref: '#/components/parameters/id'
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ requestToken:
+ type: string
+ description: Token to identify the two factor request
+ otpToken:
+ type: string
+ description: OTP token generated by the app
+ required:
+ - requestToken
+ - otpToken
+ responses:
+ '204':
+ description: successful operation
+ '403':
+ description: invalid request token or OTP token
+ '404':
+ description: user not found
+
+ /users/{id}/two-factor/disable:
+ post:
+ summary: Disable two factor auth
+ operationId: disableTwoFactor
+ description: Disable two factor authentication of a user
+ tags:
+ - Users
+ parameters:
+ - $ref: '#/components/parameters/id'
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ currentPassword:
+ type: string
+ description: Password of the currently authenticated user
+ responses:
+ '204':
+ description: successful operation
+ '403':
+ description: invalid password
+ '404':
+ description: user not found
+
+
/users/ask-send-verify-email:
post:
summary: Resend user verification link
@@ -8146,6 +8237,21 @@ components:
description: User can select live latency mode if enabled by the instance
$ref: '#/components/schemas/LiveVideoLatencyMode'
+ RequestTwoFactorResponse:
+ properties:
+ otpRequest:
+ type: object
+ properties:
+ requestToken:
+ type: string
+ description: The token to send to confirm this request
+ secret:
+ type: string
+ description: The OTP secret
+ uri:
+ type: string
+ description: The OTP URI
+
VideoStudioCreateTask:
type: array
items:
diff --git a/support/doc/docker.md b/support/doc/docker.md
index 97eecc3ad..267863a4d 100644
--- a/support/doc/docker.md
+++ b/support/doc/docker.md
@@ -49,6 +49,7 @@ In the downloaded example [.env](https://github.com/Chocobozzz/PeerTube/blob/mas
- ``
- `` without 'https://'
- ``
+- ``
Other environment variables are used in
[/support/docker/production/config/custom-environment-variables.yaml](https://github.com/Chocobozzz/PeerTube/blob/master/support/docker/production/config/custom-environment-variables.yaml) and can be
diff --git a/support/doc/production.md b/support/doc/production.md
index 64ddd9e48..b400ac451 100644
--- a/support/doc/production.md
+++ b/support/doc/production.md
@@ -115,8 +115,14 @@ $ cd /var/www/peertube
$ sudo -u peertube cp peertube-latest/config/production.yaml.example config/production.yaml
```
-Then edit the `config/production.yaml` file according to your webserver
-and database configuration (`webserver`, `database`, `redis`, `smtp` and `admin.email` sections in particular).
+Then edit the `config/production.yaml` file according to your webserver and database configuration. In particular:
+ * `webserver`: Reverse proxy public information
+ * `secrets`: Secret strings you must generate manually (PeerTube version >= 5.0)
+ * `database`: PostgreSQL settings
+ * `redis`: Redis settings
+ * `smtp`: If you want to use emails
+ * `admin.email`: To correctly fill `root` user email
+
Keys defined in `config/production.yaml` will override keys defined in `config/default.yaml`.
**PeerTube does not support webserver host change**. Even though [PeerTube CLI can help you to switch hostname](https://docs.joinpeertube.org/maintain-tools?id=update-hostjs) there's no official support for that since it is a risky operation that might result in unforeseen errors.
diff --git a/support/docker/production/.env b/support/docker/production/.env
index 4e7b21ab6..b4e356a58 100644
--- a/support/docker/production/.env
+++ b/support/docker/production/.env
@@ -22,6 +22,9 @@ PEERTUBE_WEBSERVER_HOSTNAME=
# pass them as a comma separated array:
PEERTUBE_TRUST_PROXY=["127.0.0.1", "loopback", "172.18.0.0/16"]
+# Generate one using `openssl rand -hex 32`
+PEERTUBE_SECRET=
+
# E-mail configuration
# If you use a Custom SMTP server
#PEERTUBE_SMTP_USERNAME=
diff --git a/support/docker/production/config/custom-environment-variables.yaml b/support/docker/production/config/custom-environment-variables.yaml
index 9c84428b7..1d889fe7d 100644
--- a/support/docker/production/config/custom-environment-variables.yaml
+++ b/support/docker/production/config/custom-environment-variables.yaml
@@ -7,6 +7,9 @@ webserver:
__name: "PEERTUBE_WEBSERVER_HTTPS"
__format: "json"
+secrets:
+ peertube: "PEERTUBE_SECRET"
+
trust_proxy:
__name: "PEERTUBE_TRUST_PROXY"
__format: "json"
diff --git a/yarn.lock b/yarn.lock
index 60fe262fa..8ccc4fd0d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5945,6 +5945,11 @@ jsprim@^1.2.2:
json-schema "0.4.0"
verror "1.10.0"
+jssha@~3.2.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/jssha/-/jssha-3.2.0.tgz#88ec50b866dd1411deaddbe6b3e3692e4c710f16"
+ integrity sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==
+
jstransformer@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/jstransformer/-/jstransformer-1.0.0.tgz#ed8bf0921e2f3f1ed4d5c1a44f68709ed24722c3"
@@ -7007,6 +7012,13 @@ os-tmpdir@~1.0.2:
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==
+otpauth@^8.0.3:
+ version "8.0.3"
+ resolved "https://registry.yarnpkg.com/otpauth/-/otpauth-8.0.3.tgz#fdbcb24503e93dd7d930a8651f2dc9f8f7ff9c1b"
+ integrity sha512-5abBweT/POpMdVuM0Zk/tvlTHw8Kc8606XX/w8QNLRBDib+FVpseAx12Z21/iVIeCrJOgCY1dBuLS057IOdybw==
+ dependencies:
+ jssha "~3.2.0"
+
p-cancelable@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf"