FROM_NAME_VALIDATOR,
SUBJECT_VALIDATOR
} from '@app/shared/form-validators/instance-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { InstanceService } from '@app/shared/shared-instance'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
private serverConfig: HTMLServerConfig
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private router: Router,
private modalService: NgbModal,
private instanceService: InstanceService,
MAX_INSTANCE_LIVES_VALIDATOR,
MAX_LIVE_DURATION_VALIDATOR,
MAX_USER_LIVES_VALIDATOR,
+ MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR,
SEARCH_INDEX_URL_VALIDATOR,
SERVICES_TWITTER_USERNAME_VALIDATOR,
SIGNUP_LIMIT_VALIDATOR,
SIGNUP_MINIMUM_AGE_VALIDATOR,
- TRANSCODING_THREADS_VALIDATOR,
- MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR
+ TRANSCODING_THREADS_VALIDATOR
} from '@app/shared/form-validators/custom-config-validators'
import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { CustomPageService } from '@app/shared/shared-main/custom-page'
import { CustomConfig, CustomPage, HTMLServerConfig } from '@shared/models'
import { EditConfigurationService } from './edit-configuration.service'
categoryItems: SelectOptionsItem[] = []
constructor (
+ protected formReactiveService: FormReactiveService,
private router: Router,
private route: ActivatedRoute,
- protected formValidatorService: FormValidatorService,
private notifier: Notifier,
private configService: ConfigService,
private customPage: CustomPageService,
import { Notifier } from '@app/core'
import { prepareIcu } from '@app/helpers'
import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { InstanceFollowService } from '@app/shared/shared-instance'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
private openedModal: NgbModalRef
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private followService: InstanceFollowService,
private notifier: Notifier
USER_VIDEO_QUOTA_DAILY_VALIDATOR,
USER_VIDEO_QUOTA_VALIDATOR
} from '@app/shared/form-validators/user-validators'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
import { UserAdminService } from '@app/shared/shared-users'
import { UserCreate, UserRole } from '@shared/models'
import { UserEdit } from './user-edit'
constructor (
protected serverService: ServerService,
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
protected configService: ConfigService,
protected screenService: ScreenService,
protected auth: AuthService,
</div>
-<div *ngIf="!isCreation() && user && user.pluginAuth === null" class="row mt-4"> <!-- danger zone grid -->
+<div *ngIf="displayDangerZone()" class="row mt-4"> <!-- danger zone grid -->
<div class="col-12 col-lg-4 col-xl-3">
<div class="anchor" id="danger"></div> <!-- danger zone anchor -->
<div i18n class="account-title account-title-danger">DANGER ZONE</div>
<div class="col-12 col-lg-8 col-xl-9">
<div class="danger-zone">
- <div class="form-group reset-password-email">
+ <div class="form-group">
<label i18n>Send a link to reset the password by email to the user</label>
<button (click)="resetPassword()" i18n>Ask for new password</button>
</div>
<label i18n>Manually set the user password</label>
<my-user-password [userId]="user.id"></my-user-password>
</div>
+
+ <div *ngIf="user.twoFactorEnabled" class="form-group">
+ <label i18n>This user has two factor authentication enabled</label>
+ <button (click)="disableTwoFactorAuth()" i18n>Disable two factor authentication</button>
+ </div>
</div>
</div>
}
.danger-zone {
- .reset-password-email {
- margin-bottom: 30px;
+ button {
+ @include peertube-button;
+ @include danger-button;
+ @include disable-outline;
- button {
- @include peertube-button;
- @include danger-button;
- @include disable-outline;
-
- display: block;
- margin-top: 0;
- }
+ display: block;
+ margin-top: 0;
}
}
]
}
+ displayDangerZone () {
+ if (this.isCreation()) return false
+ if (this.user?.pluginAuth) return false
+ if (this.auth.getUser().id === this.user.id) return false
+
+ return true
+ }
+
resetPassword () {
return
}
+ disableTwoFactorAuth () {
+ return
+ }
+
getUserVideoQuota () {
return this.form.value['videoQuota']
}
import { Component, Input, OnInit } from '@angular/core'
import { Notifier } from '@app/core'
import { USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { UserAdminService } from '@app/shared/shared-users'
import { UserUpdate } from '@shared/models'
@Input() userId: number
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private notifier: Notifier,
private userAdminService: UserAdminService
) {
USER_VIDEO_QUOTA_DAILY_VALIDATOR,
USER_VIDEO_QUOTA_VALIDATOR
} from '@app/shared/form-validators/user-validators'
-import { FormValidatorService } from '@app/shared/shared-forms'
-import { UserAdminService } from '@app/shared/shared-users'
+import { FormReactiveService } from '@app/shared/shared-forms'
+import { TwoFactorService, UserAdminService } from '@app/shared/shared-users'
import { User as UserType, UserAdminFlag, UserRole, UserUpdate } from '@shared/models'
import { UserEdit } from './user-edit'
private paramsSub: Subscription
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
protected serverService: ServerService,
protected configService: ConfigService,
protected screenService: ScreenService,
private router: Router,
private notifier: Notifier,
private userService: UserService,
+ private twoFactorService: TwoFactorService,
private userAdminService: UserAdminService
) {
super()
this.notifier.success($localize`An email asking for password reset has been sent to ${this.user.username}.`)
},
- error: err => {
- this.error = err.message
- }
+ error: err => this.notifier.error(err.message)
+ })
+ }
+
+ disableTwoFactorAuth () {
+ this.twoFactorService.disableTwoFactor({ userId: this.user.id })
+ .subscribe({
+ next: () => {
+ this.user.twoFactorEnabled = false
+
+ this.notifier.success($localize`Two factor authentication of ${this.user.username} disabled.`)
+ },
+
+ error: err => this.notifier.error(err.message)
})
+
}
private onUserFetched (userJson: UserType) {
@use '_variables' as *;
@use '_mixins' as *;
-@use '~bootstrap/scss/functions' as *;
+@use 'bootstrap/scss/functions' as *;
.add-button {
@include create-button;
import { ActivatedRoute } from '@angular/router'
import { HooksService, Notifier, PluginService } from '@app/core'
import { BuildFormArgument } from '@app/shared/form-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { PeerTubePlugin, RegisterServerSettingOptions } from '@shared/models'
import { PluginApiService } from '../shared/plugin-api.service'
private npmName: string
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private pluginService: PluginService,
private pluginAPIService: PluginApiService,
private notifier: Notifier,
<div class="login-form-and-externals">
<form myPluginSelector pluginSelectorId="login-form" role="form" (ngSubmit)="login()" [formGroup]="form">
- <div class="form-group">
- <div>
- <label i18n for="username">Username or email address</label>
- <input
- type="text" id="username" i18n-placeholder placeholder="Example: john@example.com" required tabindex="1"
- formControlName="username" class="form-control" [ngClass]="{ 'input-error': formErrors['username'] }" myAutofocus
- >
+ <ng-container *ngIf="!otpStep">
+ <div class="form-group">
+ <div>
+ <label i18n for="username">Username or email address</label>
+ <input
+ type="text" id="username" i18n-placeholder placeholder="Example: john@example.com" required tabindex="1"
+ formControlName="username" class="form-control" [ngClass]="{ 'input-error': formErrors['username'] }" myAutofocus
+ >
+ </div>
+
+ <div *ngIf="formErrors.username" class="form-error">{{ formErrors.username }}</div>
+
+ <div *ngIf="hasUsernameUppercase()" i18n class="form-warning">
+ ⚠️ Most email addresses do not include capital letters.
+ </div>
</div>
- <div *ngIf="formErrors.username" class="form-error">{{ formErrors.username }}</div>
+ <div class="form-group">
+ <label i18n for="password">Password</label>
- <div *ngIf="hasUsernameUppercase()" i18n class="form-warning">
- ⚠️ Most email addresses do not include capital letters.
+ <my-input-text
+ formControlName="password" inputId="password" i18n-placeholder placeholder="Password"
+ [formError]="formErrors['password']" autocomplete="current-password" [tabindex]="2"
+ ></my-input-text>
</div>
- </div>
+ </ng-container>
+
+ <div *ngIf="otpStep" class="form-group">
+ <p i18n>Enter the two-factor code generated by your phone app:</p>
- <div class="form-group">
- <label i18n for="password">Password</label>
+ <label i18n for="otp-token">Two factor authentication token</label>
<my-input-text
- formControlName="password" inputId="password" i18n-placeholder placeholder="Password"
- [formError]="formErrors['password']" autocomplete="current-password" [tabindex]="2"
+ #otpTokenInput
+ [show]="true" formControlName="otp-token" inputId="otp-token"
+ [formError]="formErrors['otp-token']" autocomplete="otp-token"
></my-input-text>
</div>
<input type="submit" class="peertube-button orange-button" i18n-value value="Login" [disabled]="!form.valid">
- <div class="additional-links">
+ <div *ngIf="!otpStep" class="additional-links">
<a i18n role="button" class="link-orange" (click)="openForgotPasswordModal()" i18n-title title="Click here to reset your password">I forgot my password</a>
<ng-container *ngIf="signupAllowed">
@use '_variables' as *;
@use '_mixins' as *;
-@import '~bootstrap/scss/functions';
-@import '~bootstrap/scss/variables';
+@import 'bootstrap/scss/functions';
+@import 'bootstrap/scss/variables';
label {
display: block;
-
import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
import { LOGIN_PASSWORD_VALIDATOR, LOGIN_USERNAME_VALIDATOR } from '@app/shared/form-validators/login-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-validators'
+import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms'
import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance'
import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { PluginsManager } from '@root-helpers/plugins-manager'
private static SESSION_STORAGE_REDIRECT_URL_KEY = 'login-previous-url'
@ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef
+ @ViewChild('otpTokenInput') otpTokenInput: InputTextComponent
accordion: NgbAccordion
error: string = null
codeOfConduct: false
}
+ otpStep = false
+
private openedForgotPasswordModal: NgbModalRef
private serverConfig: ServerConfig
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private route: ActivatedRoute,
private modalService: NgbModal,
private authService: AuthService,
// Avoid undefined errors when accessing form error properties
this.buildForm({
username: LOGIN_USERNAME_VALIDATOR,
- password: LOGIN_PASSWORD_VALIDATOR
+ password: LOGIN_PASSWORD_VALIDATOR,
+ 'otp-token': {
+ VALIDATORS: [], // Will be set dynamically
+ MESSAGES: USER_OTP_TOKEN_VALIDATOR.MESSAGES
+ }
})
this.serverConfig = snapshot.data.serverConfig
login () {
this.error = null
- const { username, password } = this.form.value
+ const options = {
+ username: this.form.value['username'],
+ password: this.form.value['password'],
+ otpToken: this.form.value['otp-token']
+ }
- this.authService.login(username, password)
+ this.authService.login(options)
+ .pipe()
.subscribe({
next: () => this.redirectService.redirectToPreviousRoute(),
- error: err => this.handleError(err)
+ error: err => {
+ this.handleError(err)
+ }
})
}
private loadExternalAuthToken (username: string, token: string) {
this.isAuthenticatedWithExternalAuth = true
- this.authService.login(username, null, token)
+ this.authService.login({ username, password: null, token })
.subscribe({
next: () => {
const redirectUrl = this.storage.getItem(LoginComponent.SESSION_STORAGE_REDIRECT_URL_KEY)
}
private handleError (err: any) {
+ if (this.authService.isOTPMissingError(err)) {
+ this.otpStep = true
+
+ setTimeout(() => {
+ this.form.get('otp-token').setValidators(USER_OTP_TOKEN_VALIDATOR.VALIDATORS)
+ this.otpTokenInput.focus()
+ })
+
+ return
+ }
+
if (err.message.indexOf('credentials are invalid') !== -1) this.error = $localize`Incorrect username or password.`
else if (err.message.indexOf('blocked') !== -1) this.error = $localize`Your account is blocked.`
else this.error = err.message
VIDEO_CHANNEL_NAME_VALIDATOR,
VIDEO_CHANNEL_SUPPORT_VALIDATOR
} from '@app/shared/form-validators/video-channel-validators'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
import { HttpStatusCode, VideoChannelCreate } from '@shared/models'
import { VideoChannelEdit } from './video-channel-edit'
private banner: FormData
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private authService: AuthService,
private notifier: Notifier,
private router: Router,
VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
VIDEO_CHANNEL_SUPPORT_VALIDATOR
} from '@app/shared/form-validators/video-channel-validators'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
import { HTMLServerConfig, VideoChannelUpdate } from '@shared/models'
import { VideoChannelEdit } from './video-channel-edit'
private serverConfig: HTMLServerConfig
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private authService: AuthService,
private notifier: Notifier,
private route: ActivatedRoute,
import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component'
import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
+import { MyAccountTwoFactorComponent } from './my-account-settings/my-account-two-factor'
import { MyAccountComponent } from './my-account.component'
const myAccountRoutes: Routes = [
}
},
+ {
+ path: 'two-factor-auth',
+ component: MyAccountTwoFactorComponent,
+ data: {
+ meta: {
+ title: $localize`Two factor authentication`
+ }
+ }
+ },
+
{
path: 'video-channels',
redirectTo: '/my-library/video-channels',
import { Component, OnInit } from '@angular/core'
import { AuthService, ServerService, UserService } from '@app/core'
import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
-import { User } from '@shared/models'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
+import { HttpStatusCode, User } from '@shared/models'
@Component({
selector: 'my-account-change-email',
user: User = null
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private authService: AuthService,
private userService: UserService,
private serverService: ServerService
},
error: err => {
- if (err.status === 401) {
+ if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
this.error = $localize`You current password is invalid.`
return
}
USER_EXISTING_PASSWORD_VALIDATOR,
USER_PASSWORD_VALIDATOR
} from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
-import { User } from '@shared/models'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
+import { HttpStatusCode, User } from '@shared/models'
@Component({
selector: 'my-account-change-password',
user: User = null
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private notifier: Notifier,
private authService: AuthService,
private userService: UserService
},
error: err => {
- if (err.status === 401) {
+ if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
this.error = $localize`You current password is invalid.`
return
}
) { }
async deleteMe () {
- const res = await this.confirmService.confirmWithInput(
+ const res = await this.confirmService.confirmWithExpectedInput(
$localize`Are you sure you want to delete your account?` +
'<br /><br />' +
// eslint-disable-next-line max-len
import { Component, Input, OnInit } from '@angular/core'
import { Notifier, User, UserService } from '@app/core'
import { USER_DESCRIPTION_VALIDATOR, USER_DISPLAY_NAME_REQUIRED_VALIDATOR } from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
@Component({
selector: 'my-account-profile',
error: string = null
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private notifier: Notifier,
private userService: UserService
) {
</div>
</div>
+<div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- two factor auth grid -->
+ <div class="col-12 col-lg-4 col-xl-3">
+ <h2 i18n class="account-title">Two-factor authentication</h2>
+ </div>
+
+ <div class="col-12 col-lg-8 col-xl-9">
+ <my-account-two-factor-button [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-two-factor-button>
+ </div>
+</div>
+
<div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- email grid -->
<div class="col-12 col-lg-4 col-xl-3">
<h2 i18n class="account-title">EMAIL</h2>
@use '_variables' as *;
@use '_mixins' as *;
-@use '~bootstrap/scss/functions' as *;
+@use 'bootstrap/scss/functions' as *;
.account-title {
@include settings-big-title;
--- /dev/null
+export * from './my-account-two-factor-button.component'
+export * from './my-account-two-factor.component'
--- /dev/null
+<div class="two-factor">
+ <ng-container *ngIf="!twoFactorEnabled">
+ <p i18n>Two factor authentication adds an additional layer of security to your account by requiring a numeric code from another device (most commonly mobile phones) when you log in.</p>
+
+ <my-button [routerLink]="[ '/my-account/two-factor-auth' ]" className="orange-button-link" i18n>Enable two-factor authentication</my-button>
+ </ng-container>
+
+ <ng-container *ngIf="twoFactorEnabled">
+ <my-button className="orange-button" (click)="disableTwoFactor()" i18n>Disable two-factor authentication</my-button>
+ </ng-container>
+
+</div>
--- /dev/null
+import { Subject } from 'rxjs'
+import { Component, Input, OnInit } from '@angular/core'
+import { AuthService, ConfirmService, Notifier, User } from '@app/core'
+import { TwoFactorService } from '@app/shared/shared-users'
+
+@Component({
+ selector: 'my-account-two-factor-button',
+ templateUrl: './my-account-two-factor-button.component.html'
+})
+export class MyAccountTwoFactorButtonComponent implements OnInit {
+ @Input() user: User = null
+ @Input() userInformationLoaded: Subject<any>
+
+ twoFactorEnabled = false
+
+ constructor (
+ private notifier: Notifier,
+ private twoFactorService: TwoFactorService,
+ private confirmService: ConfirmService,
+ private auth: AuthService
+ ) {
+ }
+
+ ngOnInit () {
+ this.userInformationLoaded.subscribe(() => {
+ this.twoFactorEnabled = this.user.twoFactorEnabled
+ })
+ }
+
+ async disableTwoFactor () {
+ const message = $localize`Are you sure you want to disable two factor authentication of your account?`
+
+ const { confirmed, password } = await this.confirmService.confirmWithPassword(message, $localize`Disable two factor`)
+ if (confirmed === false) return
+
+ this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password })
+ .subscribe({
+ next: () => {
+ this.twoFactorEnabled = false
+
+ this.auth.refreshUserInformation()
+
+ this.notifier.success($localize`Two factor authentication disabled`)
+ },
+
+ error: err => this.notifier.error(err.message)
+ })
+ }
+}
--- /dev/null
+<h1>
+ <my-global-icon iconName="cog" aria-hidden="true"></my-global-icon>
+ <ng-container i18n>Two factor authentication</ng-container>
+</h1>
+
+<div i18n *ngIf="twoFactorAlreadyEnabled === true" class="root already-enabled">
+ Two factor authentication is already enabled.
+</div>
+
+<div class="root" *ngIf="twoFactorAlreadyEnabled === false">
+ <ng-container *ngIf="step === 'request'">
+ <form role="form" (ngSubmit)="requestTwoFactor()" [formGroup]="formPassword">
+
+ <label i18n for="current-password">Your password</label>
+ <div class="form-group-description" i18n>Confirm your password to enable two factor authentication</div>
+
+ <my-input-text
+ formControlName="current-password" inputId="current-password" i18n-placeholder placeholder="Current password"
+ [formError]="formErrorsPassword['current-password']" autocomplete="current-password"
+ ></my-input-text>
+
+ <input class="peertube-button orange-button mt-3" type="submit" i18n-value value="Confirm" [disabled]="!formPassword.valid">
+ </form>
+ </ng-container>
+
+ <ng-container *ngIf="step === 'confirm'">
+
+ <p i18n>
+ Scan this QR code into a TOTP app on your phone. This app will generate tokens that you will have to enter when logging in.
+ </p>
+
+ <qrcode [qrdata]="twoFactorURI" [width]="256" level="Q"></qrcode>
+
+ <div i18n>
+ If you can't scan the QR code and need to enter it manually, here is the plain-text secret:
+ </div>
+
+ <div class="secret-plain-text">{{ twoFactorSecret }}</div>
+
+ <form class="mt-3" role="form" (ngSubmit)="confirmTwoFactor()" [formGroup]="formOTP">
+
+ <label i18n for="otp-token">Two-factor code</label>
+ <div class="form-group-description" i18n>Enter the code generated by your authenticator app to confirm</div>
+
+ <my-input-text
+ [show]="true" formControlName="otp-token" inputId="otp-token"
+ [formError]="formErrorsOTP['otp-token']" autocomplete="otp-token"
+ ></my-input-text>
+
+ <input class="peertube-button orange-button mt-3" type="submit" i18n-value value="Confirm" [disabled]="!formOTP.valid">
+ </form>
+ </ng-container>
+
+</div>
--- /dev/null
+@use '_variables' as *;
+@use '_mixins' as *;
+
+.root {
+ max-width: 600px;
+}
+
+.secret-plain-text {
+ font-family: monospace;
+ font-size: 0.9rem;
+}
+
+qrcode {
+ display: inline-block;
+ margin: auto;
+}
--- /dev/null
+import { Component, OnInit } from '@angular/core'
+import { FormGroup } from '@angular/forms'
+import { Router } from '@angular/router'
+import { AuthService, Notifier, User } from '@app/core'
+import { USER_EXISTING_PASSWORD_VALIDATOR, USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-validators'
+import { FormReactiveService } from '@app/shared/shared-forms'
+import { TwoFactorService } from '@app/shared/shared-users'
+
+@Component({
+ selector: 'my-account-two-factor',
+ templateUrl: './my-account-two-factor.component.html',
+ styleUrls: [ './my-account-two-factor.component.scss' ]
+})
+export class MyAccountTwoFactorComponent implements OnInit {
+ twoFactorAlreadyEnabled: boolean
+
+ step: 'request' | 'confirm' | 'confirmed' = 'request'
+
+ twoFactorSecret: string
+ twoFactorURI: string
+
+ inPasswordStep = true
+
+ formPassword: FormGroup
+ formErrorsPassword: any
+
+ formOTP: FormGroup
+ formErrorsOTP: any
+
+ private user: User
+ private requestToken: string
+
+ constructor (
+ private notifier: Notifier,
+ private twoFactorService: TwoFactorService,
+ private formReactiveService: FormReactiveService,
+ private auth: AuthService,
+ private router: Router
+ ) {
+ }
+
+ ngOnInit () {
+ this.buildPasswordForm()
+ this.buildOTPForm()
+
+ this.auth.userInformationLoaded.subscribe(() => {
+ this.user = this.auth.getUser()
+
+ this.twoFactorAlreadyEnabled = this.user.twoFactorEnabled
+ })
+ }
+
+ requestTwoFactor () {
+ this.twoFactorService.requestTwoFactor({
+ userId: this.user.id,
+ currentPassword: this.formPassword.value['current-password']
+ }).subscribe({
+ next: ({ otpRequest }) => {
+ this.requestToken = otpRequest.requestToken
+ this.twoFactorURI = otpRequest.uri
+ this.twoFactorSecret = otpRequest.secret.replace(/(.{4})/g, '$1 ').trim()
+
+ this.step = 'confirm'
+ },
+
+ error: err => this.notifier.error(err.message)
+ })
+ }
+
+ confirmTwoFactor () {
+ this.twoFactorService.confirmTwoFactorRequest({
+ userId: this.user.id,
+ requestToken: this.requestToken,
+ otpToken: this.formOTP.value['otp-token']
+ }).subscribe({
+ next: () => {
+ this.notifier.success($localize`Two factor authentication has been enabled.`)
+
+ this.auth.refreshUserInformation()
+
+ this.router.navigateByUrl('/my-account/settings')
+ },
+
+ error: err => this.notifier.error(err.message)
+ })
+ }
+
+ private buildPasswordForm () {
+ const { form, formErrors } = this.formReactiveService.buildForm({
+ 'current-password': USER_EXISTING_PASSWORD_VALIDATOR
+ })
+
+ this.formPassword = form
+ this.formErrorsPassword = formErrors
+ }
+
+ private buildOTPForm () {
+ const { form, formErrors } = this.formReactiveService.buildForm({
+ 'otp-token': USER_OTP_TOKEN_VALIDATOR
+ })
+
+ this.formOTP = form
+ this.formErrorsOTP = formErrors
+ }
+}
+import { QRCodeModule } from 'angularx-qrcode'
import { AutoCompleteModule } from 'primeng/autocomplete'
import { TableModule } from 'primeng/table'
import { DragDropModule } from '@angular/cdk/drag-drop'
import { SharedModerationModule } from '@app/shared/shared-moderation'
import { SharedShareModal } from '@app/shared/shared-share-modal'
import { SharedUserInterfaceSettingsModule } from '@app/shared/shared-user-settings'
+import { SharedUsersModule } from '@app/shared/shared-users'
import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
import { MyAccountAbusesListComponent } from './my-account-abuses/my-account-abuses-list.component'
import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component'
import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences'
import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
+import { MyAccountTwoFactorButtonComponent, MyAccountTwoFactorComponent } from './my-account-settings/my-account-two-factor'
import { MyAccountComponent } from './my-account.component'
@NgModule({
imports: [
MyAccountRoutingModule,
+ QRCodeModule,
AutoCompleteModule,
TableModule,
DragDropModule,
SharedFormModule,
SharedModerationModule,
SharedUserInterfaceSettingsModule,
+ SharedUsersModule,
SharedGlobalIconModule,
SharedAbuseListModule,
SharedShareModal,
MyAccountChangeEmailComponent,
MyAccountApplicationsComponent,
+ MyAccountTwoFactorButtonComponent,
+ MyAccountTwoFactorComponent,
+
MyAccountDangerZoneComponent,
MyAccountBlocklistComponent,
MyAccountAbusesListComponent,
}
async deleteVideoChannel (videoChannel: VideoChannel) {
- const res = await this.confirmService.confirmWithInput(
+ const res = await this.confirmService.confirmWithExpectedInput(
$localize`Do you really want to delete ${videoChannel.displayName}?
It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another
channel with the same name (${videoChannel.name})!`,
import { AuthService, Notifier } from '@app/core'
import { listUserChannelsForSelect } from '@app/helpers'
import { OWNERSHIP_CHANGE_CHANNEL_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { VideoOwnershipService } from '@app/shared/shared-main'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { VideoChangeOwnership } from '@shared/models'
error: string = null
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private videoOwnershipService: VideoOwnershipService,
private notifier: Notifier,
private authService: AuthService,
import { AuthService, Notifier } from '@app/core'
import { listUserChannelsForSelect } from '@app/helpers'
import { VIDEO_CHANNEL_EXTERNAL_URL_VALIDATOR } from '@app/shared/form-validators/video-channel-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { VideoChannelService, VideoChannelSyncService } from '@app/shared/shared-main'
import { VideoChannelSyncCreate } from '@shared/models/videos'
existingVideosStrategy: string
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private authService: AuthService,
private router: Router,
private notifier: Notifier,
VIDEO_PLAYLIST_DISPLAY_NAME_VALIDATOR,
VIDEO_PLAYLIST_PRIVACY_VALIDATOR
} from '@app/shared/form-validators/video-playlist-validators'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist'
import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model'
import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
error: string
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private authService: AuthService,
private notifier: Notifier,
private router: Router,
VIDEO_PLAYLIST_DISPLAY_NAME_VALIDATOR,
VIDEO_PLAYLIST_PRIVACY_VALIDATOR
} from '@app/shared/form-validators/video-playlist-validators'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
import { VideoPlaylistUpdate } from '@shared/models'
import { MyVideoPlaylistEdit } from './my-video-playlist-edit'
private paramsSub: Subscription
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private authService: AuthService,
private notifier: Notifier,
private router: Router,
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
import { Notifier, UserService } from '@app/core'
import { OWNERSHIP_CHANGE_USERNAME_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { Video, VideoOwnershipService } from '@app/shared/shared-main'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
private video: Video | undefined = undefined
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private videoOwnershipService: VideoOwnershipService,
private notifier: Notifier,
private userService: UserService,
import { Notifier, UserService } from '@app/core'
import { RESET_PASSWORD_CONFIRM_VALIDATOR } from '@app/shared/form-validators/reset-password-validators'
import { USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
@Component({
selector: 'my-login',
private verificationString: string
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private userService: UserService,
private notifier: Notifier,
private router: Router,
}
// Auto login
- this.authService.login(body.username, body.password)
+ this.authService.login({ username: body.username, password: body.password })
.subscribe({
next: () => {
this.signupSuccess = true
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { FormGroup } from '@angular/forms'
import { VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, VIDEO_CHANNEL_NAME_VALIDATOR } from '@app/shared/form-validators/video-channel-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { UserSignupService } from '@app/shared/shared-users'
@Component({
@Output() formBuilt = new EventEmitter<FormGroup>()
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private userSignupService: UserSignupService
) {
super()
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { FormGroup } from '@angular/forms'
-import {
- USER_TERMS_VALIDATOR
-} from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { USER_TERMS_VALIDATOR } from '@app/shared/form-validators/user-validators'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
@Component({
selector: 'my-register-step-terms',
@Output() codeOfConductClick = new EventEmitter<void>()
constructor (
- protected formValidatorService: FormValidatorService
+ protected formReactiveService: FormReactiveService
) {
super()
}
USER_PASSWORD_VALIDATOR,
USER_USERNAME_VALIDATOR
} from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { UserSignupService } from '@app/shared/shared-users'
@Component({
@Output() formBuilt = new EventEmitter<FormGroup>()
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private userSignupService: UserSignupService
) {
super()
import { Component, OnInit } from '@angular/core'
import { Notifier, RedirectService, ServerService } from '@app/core'
import { USER_EMAIL_VALIDATOR } from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { UserSignupService } from '@app/shared/shared-users'
@Component({
requiresEmailVerification = false
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private userSignupService: UserSignupService,
private serverService: ServerService,
private notifier: Notifier,
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { ConfirmService, Notifier, ServerService } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { VideoDetails } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { logger } from '@root-helpers/logger'
video: VideoDetails
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private serverService: ServerService,
private notifier: Notifier,
private router: Router,
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
import { ServerService } from '@app/core'
import { VIDEO_CAPTION_FILE_VALIDATOR, VIDEO_CAPTION_LANGUAGE_VALIDATOR } from '@app/shared/form-validators/video-captions-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { VideoCaptionEdit } from '@app/shared/shared-main'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { HTMLServerConfig, VideoConstant } from '@shared/models'
private closingModal = false
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private serverService: ServerService
) {
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
import { VIDEO_CAPTION_FILE_CONTENT_VALIDATOR } from '@app/shared/form-validators/video-captions-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { VideoCaptionEdit, VideoCaptionService, VideoCaptionWithPathEdit } from '@app/shared/shared-main'
-import { NgbModal, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { HTMLServerConfig, VideoConstant } from '@shared/models'
import { ServerService } from '../../../../core'
constructor (
protected openedModal: NgbActiveModal,
- protected formValidatorService: FormValidatorService,
- private modalService: NgbModal,
+ protected formReactiveService: FormReactiveService,
private videoCaptionService: VideoCaptionService,
private serverService: ServerService
) {
import { Router } from '@angular/router'
import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
import { scrollToTop } from '@app/helpers'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
import { LiveVideoService } from '@app/shared/shared-video-live'
import { LoadingBarService } from '@ngx-loading-bar/core'
error: string
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
protected loadingBar: LoadingBarService,
protected notifier: Notifier,
protected authService: AuthService,
import { Router } from '@angular/router'
import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
import { scrollToTop } from '@app/helpers'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { logger } from '@root-helpers/logger'
error: string
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
protected loadingBar: LoadingBarService,
protected notifier: Notifier,
protected authService: AuthService,
import { Router } from '@angular/router'
import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
import { scrollToTop } from '@app/helpers'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { logger } from '@root-helpers/logger'
error: string
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
protected loadingBar: LoadingBarService,
protected notifier: Notifier,
protected authService: AuthService,
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core'
import { genericUploadErrorHandler, scrollToTop } from '@app/helpers'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
import { BytesPipe, Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { logger } from '@root-helpers/logger'
private uploadServiceSubscription: Subscription
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
protected loadingBar: LoadingBarService,
protected notifier: Notifier,
protected authService: AuthService,
import { Component, HostListener, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { Notifier } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { Video, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
import { LiveVideoService } from '@app/shared/shared-video-live'
import { LoadingBarService } from '@ngx-loading-bar/core'
private updateDone = false
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private route: ActivatedRoute,
private router: Router,
private notifier: Notifier,
import { Router } from '@angular/router'
import { Notifier, User } from '@app/core'
import { VIDEO_COMMENT_TEXT_VALIDATOR } from '@app/shared/form-validators/video-comment-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { Video } from '@app/shared/shared-main'
import { VideoComment, VideoCommentService } from '@app/shared/shared-video-comment'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
private emojiMarkupList: { emoji: string, name: string }[]
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private notifier: Notifier,
private videoCommentService: VideoCommentService,
private modalService: NgbModal,
import { Hotkey, HotkeysService } from 'angular2-hotkeys'
import { Observable, ReplaySubject, Subject, throwError as observableThrowError } from 'rxjs'
import { catchError, map, mergeMap, share, tap } from 'rxjs/operators'
-import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
+import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { Notifier } from '@app/core/notification/notifier.service'
return !!this.getAccessToken()
}
- login (username: string, password: string, token?: string) {
+ login (options: {
+ username: string
+ password: string
+ otpToken?: string
+ token?: string
+ }) {
+ const { username, password, token, otpToken } = options
+
// Form url encoded
const body = {
client_id: this.clientId,
if (token) Object.assign(body, { externalAuthToken: token })
- const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded')
+ let headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded')
+ if (otpToken) headers = headers.set('x-peertube-otp', otpToken)
+
return this.http.post<UserLogin>(AuthService.BASE_TOKEN_URL, objectToUrlEncoded(body), { headers })
.pipe(
map(res => Object.assign(res, { username })),
})
}
+ isOTPMissingError (err: HttpErrorResponse) {
+ if (err.status !== HttpStatusCode.UNAUTHORIZED_401) return false
+
+ if (err.headers.get('x-peertube-otp') !== 'required; app') return false
+
+ return true
+ }
+
private mergeUserInformation (obj: UserLoginWithUsername): Observable<UserLoginWithUserInformation> {
// User is not loaded yet, set manually auth header
const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`)
-import { firstValueFrom, Subject } from 'rxjs'
+import { firstValueFrom, map, Observable, Subject } from 'rxjs'
import { Injectable } from '@angular/core'
type ConfirmOptions = {
title: string
message: string
- inputLabel?: string
- expectedInputValue?: string
- confirmButtonText?: string
-}
+} & (
+ {
+ type: 'confirm'
+ confirmButtonText?: string
+ } |
+ {
+ type: 'confirm-password'
+ confirmButtonText?: string
+ } |
+ {
+ type: 'confirm-expected-input'
+ inputLabel?: string
+ expectedInputValue?: string
+ confirmButtonText?: string
+ }
+)
@Injectable()
export class ConfirmService {
showConfirm = new Subject<ConfirmOptions>()
- confirmResponse = new Subject<boolean>()
+ confirmResponse = new Subject<{ confirmed: boolean, value?: string }>()
confirm (message: string, title = '', confirmButtonText?: string) {
- this.showConfirm.next({ title, message, confirmButtonText })
+ this.showConfirm.next({ type: 'confirm', title, message, confirmButtonText })
+
+ return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
+ }
- return firstValueFrom(this.confirmResponse.asObservable())
+ confirmWithPassword (message: string, title = '', confirmButtonText?: string) {
+ this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText })
+
+ const obs = this.confirmResponse.asObservable()
+ .pipe(map(({ confirmed, value }) => ({ confirmed, password: value })))
+
+ return firstValueFrom(obs)
}
- confirmWithInput (message: string, inputLabel: string, expectedInputValue: string, title = '', confirmButtonText?: string) {
- this.showConfirm.next({ title, message, inputLabel, expectedInputValue, confirmButtonText })
+ confirmWithExpectedInput (message: string, inputLabel: string, expectedInputValue: string, title = '', confirmButtonText?: string) {
+ this.showConfirm.next({ type: 'confirm-expected-input', title, message, inputLabel, expectedInputValue, confirmButtonText })
+
+ return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
+ }
- return firstValueFrom(this.confirmResponse.asObservable())
+ private extractConfirmed (obs: Observable<{ confirmed: boolean }>) {
+ return obs.pipe(map(({ confirmed }) => confirmed))
}
}
import { DateFormat, dateToHuman } from '@app/helpers'
import { logger } from '@root-helpers/logger'
import { HttpStatusCode, ResultList } from '@shared/models'
+import { HttpHeaderResponse } from '@angular/common/http'
@Injectable()
export class RestExtractor {
handleError (err: any) {
const errorMessage = this.buildErrorMessage(err)
- const errorObj: { message: string, status: string, body: string } = {
+ const errorObj: { message: string, status: string, body: string, headers: HttpHeaderResponse } = {
message: errorMessage,
status: undefined,
- body: undefined
+ body: undefined,
+ headers: err.headers
}
if (err.status) {
lastLoginDate: Date | null
+ twoFactorEnabled: boolean
+
createdAt: Date
constructor (hash: Partial<UserServerModel>) {
this.notificationSettings = hash.notificationSettings
+ this.twoFactorEnabled = hash.twoFactorEnabled
+
this.createdAt = hash.createdAt
this.pluginAuth = hash.pluginAuth
<div class="modal-body" >
<div [innerHtml]="message"></div>
- <div *ngIf="inputLabel && expectedInputValue" class="form-group mt-3">
+ <div *ngIf="inputLabel" class="form-group mt-3">
<label for="confirmInput">{{ inputLabel }}</label>
- <input type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" />
+
+ <input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" />
+
+ <my-input-text inputId="confirmInput" [(ngModel)]="inputValue"></my-input-text>
</div>
</div>
inputValue = ''
confirmButtonText = ''
+ isPasswordInput = false
+
private openedModal: NgbModalRef
constructor (
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`
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 })
}
})
}
}
}
+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,
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'
private abuse: UserAbuse
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private htmlRenderer: HtmlRendererService,
private auth: AuthService,
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'
private openedModal: NgbModalRef
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private notifier: Notifier,
private abuseService: AbuseService
--- /dev/null
+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')
+ }
+ }
+ }
+}
-
-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
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)
}
}
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 {
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'
-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'
]
})
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'
update () {
this.propagateChange(this.value)
}
+
+ focus () {
+ const el: HTMLElement = this.inputElement.nativeElement
+
+ el.focus({ preventScroll: true })
+ }
}
-
import { InputMaskModule } from 'primeng/inputmask'
import { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
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'
],
providers: [
- FormValidatorService
+ FormValidatorService,
+ FormReactiveService
]
})
export class SharedFormModule { }
.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)
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'
private openedModal: NgbModalRef
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal
) {
super()
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'
private openedModal: NgbModalRef
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private abuseService: AbuseService,
private notifier: Notifier
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'
private openedModal: NgbModalRef
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private abuseService: AbuseService,
private notifier: Notifier
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'
private openedModal: NgbModalRef
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private abuseService: AbuseService,
private notifier: Notifier,
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'
modalMessage = ''
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private notifier: Notifier,
private userAdminService: UserAdminService,
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'
private openedModal: NgbModalRef
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private videoBlocklistService: VideoBlockService,
private notifier: Notifier
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'
private serverConfig: HTMLServerConfig
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private authService: AuthService,
private notifier: Notifier,
private userService: UserService,
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'
formValuesWatcher: Subscription
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private authService: AuthService,
private notifier: Notifier,
private userService: UserService,
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'
@Input() showHelp = false
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private notifier: Notifier
) {
super()
export * from './user-admin.service'
export * from './user-signup.service'
+export * from './two-factor.service'
export * from './shared-users.module'
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'
providers: [
UserSignupService,
- UserAdminService
+ UserAdminService,
+ TwoFactorService
]
})
export class SharedUsersModule { }
--- /dev/null
+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<TwoFactorEnableResult>(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)))
+ }
+}
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,
private pendingAddId: number
constructor (
- protected formValidatorService: FormValidatorService,
+ protected formReactiveService: FormReactiveService,
private authService: AuthService,
private notifier: Notifier,
private videoPlaylistService: VideoPlaylistService,
@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/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/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/helpers';
+@import 'bootstrap/scss/utilities/api';
.accordion {
--bs-accordion-color: #{pvar(--mainForegroundColor)};
@use 'sass:math';
@use 'sass:color';
-@use '~bootstrap/scss/functions' as *;
+@use 'bootstrap/scss/functions' as *;
$small-view: 800px;
$mobile-view: 500px;
$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;
-@use '~bootstrap/scss/functions' as *;
+@use 'bootstrap/scss/functions' as *;
$primary-foreground-color: #fff;
$primary-foreground-opacity: 0.9;
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
webserver:
https: false
+secrets:
+ peertube: 'my super dev secret'
+
database:
hostname: 'localhost'
port: 5432
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
webserver:
https: false
+secrets:
+ peertube: 'my super secret'
+
rates_limit:
signup:
window: 10 minutes
"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",
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)
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')
})
const usersRouter = express.Router()
+usersRouter.use('/', twoFactorRouter)
usersRouter.use('/', tokensRouter)
usersRouter.use('/', myNotificationsRouter)
usersRouter.use('/', mySubscriptionsRouter)
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'
} 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,
--- /dev/null
+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)
+}
*/
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'
}
}
+// eslint-disable-next-line max-len
+function promisify3<T, U, V, A> (func: (arg1: T, arg2: U, arg3: V, cb: (err: any, result: A) => void) => void): (arg1: T, arg2: U, arg3: V) => Promise<A> {
+ return function promisified (arg1: T, arg2: U, arg3: V): Promise<A> {
+ return new Promise<A>((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<number, Buffer>(randomBytes)
+const scryptPromise = promisify3<string, string, number, Buffer>(scrypt)
const execPromise2 = promisify2<string, any, string>(exec)
const execPromise = promisify1<string, string>(exec)
const pipelinePromise = promisify(pipeline)
promisify1,
promisify2,
+ scryptPromise,
+
randomBytesPromise,
generateRSAKeyPairPromise,
--- /dev/null
+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
+ }
+}
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'
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)
}
return bcryptHashPromise(password, salt)
}
+// ---------------------------------------------------------------------------
// HTTP Signature
+// ---------------------------------------------------------------------------
function isHTTPSignatureDigestValid (rawBody: Buffer, req: Request): boolean {
if (req.headers[HTTP_SIGNATURE.HEADER_NAME] && req.headers['digest']) {
return parsed
}
+// ---------------------------------------------------------------------------
// JSONLD
+// ---------------------------------------------------------------------------
function isJsonLDSignatureVerified (fromActor: MActor, signedDocument: any): Promise<boolean> {
if (signedDocument.signature.type === 'RsaSignature2017') {
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 {
comparePassword,
createPrivateAndPublicKeys,
cryptPassword,
- signJsonLDObject
+ signJsonLDObject,
+
+ encrypt,
+ decrypt
}
// ---------------------------------------------------------------------------
logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.')
}
+ checkSecretsConfig()
checkEmailConfig()
checkNSFWPolicyConfig()
checkLocalRedundancyConfig()
// ---------------------------------------------------------------------------
+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) {
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',
PORT: config.get<number>('listen.port'),
HOSTNAME: config.get<string>('listen.hostname')
},
+ SECRETS: {
+ PEERTUBE: config.get<string>('secrets.peertube')
+ },
DATABASE: {
DBNAME: config.has('database.name') ? config.get<string>('database.name') : 'peertube' + config.get<string>('database.suffix'),
HOSTNAME: config.get<string>('database.hostname'),
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'
// ---------------------------------------------------------------------------
-const LAST_MIGRATION_VERSION = 740
+const LAST_MIGRATION_VERSION = 745
// ---------------------------------------------------------------------------
// 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 } = {
}
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'),
export {
WEBSERVER,
API_VERSION,
+ ENCRYPTION,
VIDEO_LIVE,
PEERTUBE_VERSION,
LAZY_STATIC_PATHS,
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,
PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME,
ASSETS_PATH,
FILES_CONTENT_HASH,
+ OTP,
loadLanguages,
buildLanguages,
generateContentHash
--- /dev/null
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+ transaction: Sequelize.Transaction
+ queryInterface: Sequelize.QueryInterface
+ sequelize: Sequelize.Sequelize
+ db: any
+}): Promise<void> {
+ 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
+}
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
}
export {
+ MissingTwoFactorError,
+ InvalidTwoFactorError,
+
handleOAuthToken,
handleOAuthAuthenticate
}
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 })
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,
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) {
return 'reset-password-' + userId
}
+ private generateTwoFactorRequestKey (userId: number, token: string) {
+ return 'two-factor-request-' + userId + '-' + token
+ }
+
private generateVerifyEmailKey (userId: number) {
return 'verify-email-' + userId
}
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) {
export * from './abuses'
export * from './accounts'
+export * from './users'
export * from './utils'
export * from './video-blacklists'
export * from './video-captions'
--- /dev/null
+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<MUserDefault>, 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
+}
--- /dev/null
+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
+}
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,
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')
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({
}
]
+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()
usersUpdateValidator,
usersUpdateMeValidator,
usersVideoRatingValidator,
+ usersCheckCurrentPasswordFactory,
ensureUserRegistrationAllowed,
ensureUserRegistrationAllowedForIP,
usersGetValidator,
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<MUserDefault>, 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
-}
@Column
lastLoginDate: Date
+ @AllowNull(true)
+ @Default(null)
+ @Column
+ otpSecret: string
+
@CreatedAt
createdAt: Date
pluginAuth: this.pluginAuth,
- lastLoginDate: this.lastLoginDate
+ lastLoginDate: this.lastLoginDate,
+
+ twoFactorEnabled: !!this.otpSecret
}
if (parameters.withAdminFlags) {
import './accounts'
import './blocklist'
import './bulk'
+import './channel-import-videos'
import './config'
import './contact-form'
import './custom-pages'
import './search'
import './services'
import './transcoding'
+import './two-factor'
import './upload-quota'
import './user-notifications'
import './user-subscriptions'
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'
--- /dev/null
+/* 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 ])
+ })
+})
+import './two-factor'
import './user-subscriptions'
import './user-videos'
import './users'
--- /dev/null
+/* 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 ])
+ })
+})
--- /dev/null
+/* 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
+ })
+})
-import './image'
+import './crypto'
import './core-utils'
import './dns'
+import './dns'
import './comment-model'
import './markdown'
import './request'
+export * from './two-factor-enable-result.model'
export * from './user-create-result.model'
export * from './user-create.model'
export * from './user-flag.model'
--- /dev/null
+export interface TwoFactorEnableResult {
+ otpRequest: {
+ requestToken: string
+ secret: string
+ uri: string
+ }
+}
pluginAuth: string | null
lastLoginDate: Date | null
+
+ twoFactorEnabled: boolean
}
export interface MyUserSpecialPlaylist {
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,
videos?: VideosCommand
videoStats?: VideoStatsCommand
views?: ViewsCommand
+ twoFactor?: TwoFactorCommand
constructor (options: { serverNumber: number } | { url: string }) {
if ((options as any).url) {
this.videoStudio = new VideoStudioCommand(this)
this.videoStats = new VideoStatsCommand(this)
this.views = new ViewsCommand(this)
+ this.twoFactor = new TwoFactorCommand(this)
}
}
export * from './login-command'
export * from './notifications-command'
export * from './subscriptions-command'
+export * from './two-factor-command'
export * from './users-command'
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)
+ }
- return unwrapBody<{ access_token: string, refresh_token: string } & PeerTubeProblemDocument>(this.postBodyRequest({
- ...options,
+ async loginAndGetResponse (options: LoginOptions = {}) {
+ const res = await this._login(options)
- path,
- requestType: 'form',
- fields: body,
- implicitToken: false,
- defaultExpectedStatus: HttpStatusCode.OK_200
- }))
+ return {
+ res,
+ body: this.unwrapLoginBody(res.body)
+ }
}
getAccessToken (arg1?: { username: string, password?: string }): Promise<string>
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
+ }
}
--- /dev/null
+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<TwoFactorEnableResult>(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
+ }
+}
token,
userId: user.id,
userChannelId: me.videoChannels[0].id,
- userChannelName: me.videoChannels[0].name
+ userChannelName: me.videoChannels[0].name,
+ password
}
}
'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
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:
- `<MY POSTGRES PASSWORD>`
- `<MY DOMAIN>` without 'https://'
- `<MY EMAIL ADDRESS>`
+- `<MY PEERTUBE SECRET>`
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
$ 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.
# 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=<MY PEERTUBE SECRET>
+
# E-mail configuration
# If you use a Custom SMTP server
#PEERTUBE_SMTP_USERNAME=
__name: "PEERTUBE_WEBSERVER_HTTPS"
__format: "json"
+secrets:
+ peertube: "PEERTUBE_SECRET"
+
trust_proxy:
__name: "PEERTUBE_TRUST_PROXY"
__format: "json"
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"
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"