mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2025-10-06 03:50:26 +02:00
Add ability to list and revoke token sessions
This commit is contained in:
parent
a53ed039b8
commit
57caf25611
40 changed files with 1158 additions and 138 deletions
|
@ -1,8 +1,7 @@
|
||||||
import { NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'
|
import { CommonModule, NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, OnInit, inject } from '@angular/core'
|
import { Component, OnInit, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { Router, RouterLink } from '@angular/router'
|
import { Router, RouterLink } from '@angular/router'
|
||||||
import { AdminConfigService } from '@app/shared/shared-admin/admin-config.service'
|
|
||||||
import { AuthService, Notifier, ScreenService, ServerService } from '@app/core'
|
import { AuthService, Notifier, ScreenService, ServerService } from '@app/core'
|
||||||
import {
|
import {
|
||||||
USER_CHANNEL_NAME_VALIDATOR,
|
USER_CHANNEL_NAME_VALIDATOR,
|
||||||
|
@ -14,15 +13,16 @@ import {
|
||||||
USER_VIDEO_QUOTA_DAILY_VALIDATOR,
|
USER_VIDEO_QUOTA_DAILY_VALIDATOR,
|
||||||
USER_VIDEO_QUOTA_VALIDATOR
|
USER_VIDEO_QUOTA_VALIDATOR
|
||||||
} from '@app/shared/form-validators/user-validators'
|
} from '@app/shared/form-validators/user-validators'
|
||||||
|
import { AdminConfigService } from '@app/shared/shared-admin/admin-config.service'
|
||||||
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
|
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
|
||||||
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
|
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
|
||||||
|
import { AccountTokenSessionsComponent } from '@app/shared/shared-users/account-token-sessions.component'
|
||||||
import { UserAdminService } from '@app/shared/shared-users/user-admin.service'
|
import { UserAdminService } from '@app/shared/shared-users/user-admin.service'
|
||||||
import { UserCreate, UserRole } from '@peertube/peertube-models'
|
import { UserCreate, UserRole } from '@peertube/peertube-models'
|
||||||
import { ActorAvatarEditComponent } from '../../../../shared/shared-actor-image-edit/actor-avatar-edit.component'
|
import { ActorAvatarEditComponent } from '../../../../shared/shared-actor-image-edit/actor-avatar-edit.component'
|
||||||
import { InputTextComponent } from '../../../../shared/shared-forms/input-text.component'
|
import { InputTextComponent } from '../../../../shared/shared-forms/input-text.component'
|
||||||
import { PeertubeCheckboxComponent } from '../../../../shared/shared-forms/peertube-checkbox.component'
|
import { PeertubeCheckboxComponent } from '../../../../shared/shared-forms/peertube-checkbox.component'
|
||||||
import { SelectCustomValueComponent } from '../../../../shared/shared-forms/select/select-custom-value.component'
|
import { SelectCustomValueComponent } from '../../../../shared/shared-forms/select/select-custom-value.component'
|
||||||
import { HelpComponent } from '../../../../shared/shared-main/buttons/help.component'
|
|
||||||
import { BytesPipe } from '../../../../shared/shared-main/common/bytes.pipe'
|
import { BytesPipe } from '../../../../shared/shared-main/common/bytes.pipe'
|
||||||
import { UserRealQuotaInfoComponent } from '../../../shared/user-real-quota-info.component'
|
import { UserRealQuotaInfoComponent } from '../../../shared/user-real-quota-info.component'
|
||||||
import { UserEdit } from './user-edit'
|
import { UserEdit } from './user-edit'
|
||||||
|
@ -34,20 +34,18 @@ import { UserPasswordComponent } from './user-password.component'
|
||||||
styleUrls: [ './user-edit.component.scss' ],
|
styleUrls: [ './user-edit.component.scss' ],
|
||||||
imports: [
|
imports: [
|
||||||
RouterLink,
|
RouterLink,
|
||||||
NgIf,
|
CommonModule,
|
||||||
NgTemplateOutlet,
|
NgTemplateOutlet,
|
||||||
ActorAvatarEditComponent,
|
ActorAvatarEditComponent,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgClass,
|
|
||||||
HelpComponent,
|
|
||||||
InputTextComponent,
|
InputTextComponent,
|
||||||
NgFor,
|
|
||||||
SelectCustomValueComponent,
|
SelectCustomValueComponent,
|
||||||
UserRealQuotaInfoComponent,
|
UserRealQuotaInfoComponent,
|
||||||
PeertubeCheckboxComponent,
|
PeertubeCheckboxComponent,
|
||||||
UserPasswordComponent,
|
UserPasswordComponent,
|
||||||
BytesPipe,
|
BytesPipe,
|
||||||
|
AccountTokenSessionsComponent,
|
||||||
AlertComponent
|
AlertComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
|
@ -123,11 +123,11 @@
|
||||||
<div class="form-group" *ngIf="isCreation()">
|
<div class="form-group" *ngIf="isCreation()">
|
||||||
<label i18n for="password">Password</label>
|
<label i18n for="password">Password</label>
|
||||||
|
|
||||||
<my-help *ngIf="isPasswordOptional()">
|
@if (isPasswordOptional()) {
|
||||||
<ng-container i18n>
|
<div class="form-group-description" i18n>
|
||||||
If you leave the password empty, an email will be sent to the user.
|
If you leave the password empty, an email will be sent to the user.
|
||||||
</ng-container>
|
</div>
|
||||||
</my-help>
|
}
|
||||||
|
|
||||||
<my-input-text formControlName="password" inputId="password" [formError]="formErrors['password']" autocomplete="new-password"></my-input-text>
|
<my-input-text formControlName="password" inputId="password" [formError]="formErrors['password']" autocomplete="new-password"></my-input-text>
|
||||||
</div>
|
</div>
|
||||||
|
@ -215,16 +215,27 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (displayTokenSessions()) {
|
||||||
|
<div class="pt-two-cols mt-5">
|
||||||
|
<div class="title-col">
|
||||||
|
<div class="anchor" id="token-sessions"></div>
|
||||||
|
<h2 i18n>TOKEN SESSIONS</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div *ngIf="displayDangerZone()" class="pt-two-cols mt-5"> <!-- danger zone grid -->
|
<div class="content-col">
|
||||||
<div class="title-col">
|
<my-account-token-sessions [user]="user"></my-account-token-sessions>
|
||||||
<div class="anchor" id="danger"></div> <!-- danger zone anchor -->
|
</div>
|
||||||
<h2 i18n class="pt-title-danger">DANGER ZONE</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="content-col">
|
@if (displayPasswordZone()) {
|
||||||
|
<div class="pt-two-cols mt-5">
|
||||||
|
<div class="title-col">
|
||||||
|
<div class="anchor" id="danger"></div>
|
||||||
|
<h2 i18n class="pt-title-danger">DANGER ZONE</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="danger-zone">
|
<div class="content-col">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="mb-1 fw-bold" i18n>Send a link to reset the password by email to the user</div>
|
<div class="mb-1 fw-bold" i18n>Send a link to reset the password by email to the user</div>
|
||||||
<button class="peertube-button danger-button" (click)="resetPassword()" i18n>Ask for new password</button>
|
<button class="peertube-button danger-button" (click)="resetPassword()" i18n>Ask for new password</button>
|
||||||
|
@ -240,6 +251,5 @@
|
||||||
<button class="peertube-button danger-button" (click)="disableTwoFactorAuth()" i18n>Disable two factor authentication</button>
|
<button class="peertube-button danger-button" (click)="disableTwoFactorAuth()" i18n>Disable two factor authentication</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Directive, OnInit } from '@angular/core'
|
import { Directive, OnInit } from '@angular/core'
|
||||||
import { AdminConfigService } from '@app/shared/shared-admin/admin-config.service'
|
|
||||||
import { getVideoQuotaDailyOptions, getVideoQuotaOptions } from '@app/+admin/shared/user-quota-options'
|
import { getVideoQuotaDailyOptions, getVideoQuotaOptions } from '@app/+admin/shared/user-quota-options'
|
||||||
import { AuthService, ScreenService, ServerService, User } from '@app/core'
|
import { AuthService, ScreenService, ServerService, User } from '@app/core'
|
||||||
|
import { AdminConfigService } from '@app/shared/shared-admin/admin-config.service'
|
||||||
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
|
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
|
||||||
import { peertubeTranslate, USER_ROLE_LABELS } from '@peertube/peertube-core-utils'
|
import { peertubeTranslate, USER_ROLE_LABELS } from '@peertube/peertube-core-utils'
|
||||||
import { HTMLServerConfig, UserAdminFlag, UserRole } from '@peertube/peertube-models'
|
import { HTMLServerConfig, UserAdminFlag, UserRole } from '@peertube/peertube-models'
|
||||||
|
@ -63,7 +63,7 @@ export abstract class UserEdit extends FormReactive implements OnInit {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
displayDangerZone () {
|
displayPasswordZone () {
|
||||||
if (this.isCreation()) return false
|
if (this.isCreation()) return false
|
||||||
if (!this.user) return false
|
if (!this.user) return false
|
||||||
if (this.user.pluginAuth) return false
|
if (this.user.pluginAuth) return false
|
||||||
|
@ -72,6 +72,13 @@ export abstract class UserEdit extends FormReactive implements OnInit {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
displayTokenSessions () {
|
||||||
|
if (this.isCreation()) return false
|
||||||
|
if (!this.user) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
resetPassword () {
|
resetPassword () {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import { NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'
|
import { CommonModule, NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router'
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router'
|
||||||
import { AdminConfigService } from '@app/shared/shared-admin/admin-config.service'
|
|
||||||
import { AuthService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core'
|
import { AuthService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core'
|
||||||
import {
|
import {
|
||||||
USER_EMAIL_VALIDATOR,
|
USER_EMAIL_VALIDATOR,
|
||||||
|
@ -10,6 +9,7 @@ import {
|
||||||
USER_VIDEO_QUOTA_DAILY_VALIDATOR,
|
USER_VIDEO_QUOTA_DAILY_VALIDATOR,
|
||||||
USER_VIDEO_QUOTA_VALIDATOR
|
USER_VIDEO_QUOTA_VALIDATOR
|
||||||
} from '@app/shared/form-validators/user-validators'
|
} from '@app/shared/form-validators/user-validators'
|
||||||
|
import { AdminConfigService } from '@app/shared/shared-admin/admin-config.service'
|
||||||
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
|
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
|
||||||
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
|
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
|
||||||
import { TwoFactorService } from '@app/shared/shared-users/two-factor.service'
|
import { TwoFactorService } from '@app/shared/shared-users/two-factor.service'
|
||||||
|
@ -20,11 +20,11 @@ import { ActorAvatarEditComponent } from '../../../../shared/shared-actor-image-
|
||||||
import { InputTextComponent } from '../../../../shared/shared-forms/input-text.component'
|
import { InputTextComponent } from '../../../../shared/shared-forms/input-text.component'
|
||||||
import { PeertubeCheckboxComponent } from '../../../../shared/shared-forms/peertube-checkbox.component'
|
import { PeertubeCheckboxComponent } from '../../../../shared/shared-forms/peertube-checkbox.component'
|
||||||
import { SelectCustomValueComponent } from '../../../../shared/shared-forms/select/select-custom-value.component'
|
import { SelectCustomValueComponent } from '../../../../shared/shared-forms/select/select-custom-value.component'
|
||||||
import { HelpComponent } from '../../../../shared/shared-main/buttons/help.component'
|
|
||||||
import { BytesPipe } from '../../../../shared/shared-main/common/bytes.pipe'
|
import { BytesPipe } from '../../../../shared/shared-main/common/bytes.pipe'
|
||||||
import { UserRealQuotaInfoComponent } from '../../../shared/user-real-quota-info.component'
|
import { UserRealQuotaInfoComponent } from '../../../shared/user-real-quota-info.component'
|
||||||
import { UserEdit } from './user-edit'
|
import { UserEdit } from './user-edit'
|
||||||
import { UserPasswordComponent } from './user-password.component'
|
import { UserPasswordComponent } from './user-password.component'
|
||||||
|
import { AccountTokenSessionsComponent } from '@app/shared/shared-users/account-token-sessions.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-user-update',
|
selector: 'my-user-update',
|
||||||
|
@ -32,19 +32,17 @@ import { UserPasswordComponent } from './user-password.component'
|
||||||
styleUrls: [ './user-edit.component.scss' ],
|
styleUrls: [ './user-edit.component.scss' ],
|
||||||
imports: [
|
imports: [
|
||||||
RouterLink,
|
RouterLink,
|
||||||
NgIf,
|
|
||||||
NgTemplateOutlet,
|
NgTemplateOutlet,
|
||||||
ActorAvatarEditComponent,
|
ActorAvatarEditComponent,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgClass,
|
CommonModule,
|
||||||
HelpComponent,
|
|
||||||
InputTextComponent,
|
InputTextComponent,
|
||||||
NgFor,
|
|
||||||
SelectCustomValueComponent,
|
SelectCustomValueComponent,
|
||||||
UserRealQuotaInfoComponent,
|
UserRealQuotaInfoComponent,
|
||||||
PeertubeCheckboxComponent,
|
PeertubeCheckboxComponent,
|
||||||
UserPasswordComponent,
|
UserPasswordComponent,
|
||||||
|
AccountTokenSessionsComponent,
|
||||||
BytesPipe,
|
BytesPipe,
|
||||||
AlertComponent
|
AlertComponent
|
||||||
]
|
]
|
||||||
|
|
|
@ -72,6 +72,18 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-two-cols mt-5">
|
||||||
|
<div class="title-col">
|
||||||
|
<h2 i18n>ACCOUNT SESSIONS</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-col">
|
||||||
|
<p i18n>These are the devices currently logged in to your account.</p>
|
||||||
|
|
||||||
|
<my-account-token-sessions [user]="user"></my-account-token-sessions>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="pt-two-cols mt-5" *ngIf="user.pluginAuth === null"> <!-- email grid -->
|
<div class="pt-two-cols mt-5" *ngIf="user.pluginAuth === null"> <!-- email grid -->
|
||||||
<div class="title-col">
|
<div class="title-col">
|
||||||
<h2 i18n>EMAIL</h2>
|
<h2 i18n>EMAIL</h2>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
@use '_variables' as *;
|
@use "_variables" as *;
|
||||||
@use '_mixins' as *;
|
@use "_mixins" as *;
|
||||||
|
|
||||||
.content-col {
|
.content-col {
|
||||||
max-width: 500px;;
|
max-width: 800px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,40 +1,42 @@
|
||||||
import { ViewportScroller, NgIf } from '@angular/common'
|
import { CommonModule, ViewportScroller } from '@angular/common'
|
||||||
import { HttpErrorResponse } from '@angular/common/http'
|
import { HttpErrorResponse } from '@angular/common/http'
|
||||||
import { AfterViewChecked, Component, OnInit, inject } from '@angular/core'
|
import { AfterViewChecked, Component, OnInit, inject } from '@angular/core'
|
||||||
import { AuthService, Notifier, User, UserService } from '@app/core'
|
import { AuthService, Notifier, User, UserService } from '@app/core'
|
||||||
import { genericUploadErrorHandler } from '@app/helpers'
|
import { genericUploadErrorHandler } from '@app/helpers'
|
||||||
import { shallowCopy } from '@peertube/peertube-core-utils'
|
import { shallowCopy } from '@peertube/peertube-core-utils'
|
||||||
import { MyAccountDangerZoneComponent } from './my-account-danger-zone/my-account-danger-zone.component'
|
import { ActorAvatarEditComponent } from '../../shared/shared-actor-image-edit/actor-avatar-edit.component'
|
||||||
|
import { UserQuotaComponent } from '../../shared/shared-main/users/user-quota.component'
|
||||||
|
import { UserInterfaceSettingsComponent } from '../../shared/shared-user-settings/user-interface-settings.component'
|
||||||
|
import { UserVideoSettingsComponent } from '../../shared/shared-user-settings/user-video-settings.component'
|
||||||
|
import { AccountTokenSessionsComponent } from '../../shared/shared-users/account-token-sessions.component'
|
||||||
import { MyAccountChangeEmailComponent } from './my-account-change-email/my-account-change-email.component'
|
import { MyAccountChangeEmailComponent } from './my-account-change-email/my-account-change-email.component'
|
||||||
import { MyAccountEmailPreferencesComponent } from './my-account-email-preferences/my-account-email-preferences.component'
|
|
||||||
import { MyAccountTwoFactorButtonComponent } from './my-account-two-factor/my-account-two-factor-button.component'
|
|
||||||
import { MyAccountChangePasswordComponent } from './my-account-change-password/my-account-change-password.component'
|
import { MyAccountChangePasswordComponent } from './my-account-change-password/my-account-change-password.component'
|
||||||
|
import { MyAccountDangerZoneComponent } from './my-account-danger-zone/my-account-danger-zone.component'
|
||||||
|
import { MyAccountEmailPreferencesComponent } from './my-account-email-preferences/my-account-email-preferences.component'
|
||||||
import {
|
import {
|
||||||
MyAccountNotificationPreferencesComponent
|
MyAccountNotificationPreferencesComponent
|
||||||
} from './my-account-notification-preferences/my-account-notification-preferences.component'
|
} from './my-account-notification-preferences/my-account-notification-preferences.component'
|
||||||
import { UserVideoSettingsComponent } from '../../shared/shared-user-settings/user-video-settings.component'
|
|
||||||
import { UserInterfaceSettingsComponent } from '../../shared/shared-user-settings/user-interface-settings.component'
|
|
||||||
import { MyAccountProfileComponent } from './my-account-profile/my-account-profile.component'
|
import { MyAccountProfileComponent } from './my-account-profile/my-account-profile.component'
|
||||||
import { UserQuotaComponent } from '../../shared/shared-main/users/user-quota.component'
|
import { MyAccountTwoFactorButtonComponent } from './my-account-two-factor/my-account-two-factor-button.component'
|
||||||
import { ActorAvatarEditComponent } from '../../shared/shared-actor-image-edit/actor-avatar-edit.component'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-account-settings',
|
selector: 'my-account-settings',
|
||||||
templateUrl: './my-account-settings.component.html',
|
templateUrl: './my-account-settings.component.html',
|
||||||
styleUrls: [ './my-account-settings.component.scss' ],
|
styleUrls: [ './my-account-settings.component.scss' ],
|
||||||
imports: [
|
imports: [
|
||||||
|
CommonModule,
|
||||||
ActorAvatarEditComponent,
|
ActorAvatarEditComponent,
|
||||||
UserQuotaComponent,
|
UserQuotaComponent,
|
||||||
MyAccountProfileComponent,
|
MyAccountProfileComponent,
|
||||||
UserInterfaceSettingsComponent,
|
UserInterfaceSettingsComponent,
|
||||||
UserVideoSettingsComponent,
|
UserVideoSettingsComponent,
|
||||||
MyAccountNotificationPreferencesComponent,
|
MyAccountNotificationPreferencesComponent,
|
||||||
NgIf,
|
|
||||||
MyAccountChangePasswordComponent,
|
MyAccountChangePasswordComponent,
|
||||||
MyAccountTwoFactorButtonComponent,
|
MyAccountTwoFactorButtonComponent,
|
||||||
MyAccountEmailPreferencesComponent,
|
MyAccountEmailPreferencesComponent,
|
||||||
MyAccountChangeEmailComponent,
|
MyAccountChangeEmailComponent,
|
||||||
MyAccountDangerZoneComponent
|
MyAccountDangerZoneComponent,
|
||||||
|
AccountTokenSessionsComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class MyAccountSettingsComponent implements OnInit, AfterViewChecked {
|
export class MyAccountSettingsComponent implements OnInit, AfterViewChecked {
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
<div class="root">
|
||||||
|
<table class="pt-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th i18n>Device</th>
|
||||||
|
<th i18n>IP</th>
|
||||||
|
<th i18n>Last Active</th>
|
||||||
|
<th i18n>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
@if (sessions.length === 0) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center" i18n>No active sessions</td>
|
||||||
|
</tr>
|
||||||
|
} @else {
|
||||||
|
<tr *ngFor="let session of sessions">
|
||||||
|
<td>{{ session.browserName }} {{ session.browserVersion }} on {{ session.osName }} {{ session.osVersion }}</td>
|
||||||
|
|
||||||
|
<td class="font-monospace">{{ session.lastActivityIP }}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
@if (session.currentSession) {
|
||||||
|
<span class="current-session" i18n>Current Session</span>
|
||||||
|
} @else {
|
||||||
|
<span class="last-active">{{ session.lastActivityDate | date: 'short' }}</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
@if (!session.currentSession) {
|
||||||
|
<button class="peertube-button secondary-button" (click)="revokeSession(session)" i18n>Revoke</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
|
@ -0,0 +1,13 @@
|
||||||
|
@use "_variables" as *;
|
||||||
|
@use "_mixins" as *;
|
||||||
|
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
|
||||||
|
@include padding-right(1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
@include font-size(14px);
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { Component, OnInit, inject, input } from '@angular/core'
|
||||||
|
import { ConfirmService, Notifier } from '@app/core'
|
||||||
|
import { TokenSessionService } from '@app/shared/shared-users/token-session.service'
|
||||||
|
import { TokenSession, User } from '@peertube/peertube-models'
|
||||||
|
import { UAParser } from 'ua-parser-js'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'my-account-token-sessions',
|
||||||
|
templateUrl: './account-token-sessions.component.html',
|
||||||
|
styleUrls: [ './account-token-sessions.component.scss' ],
|
||||||
|
imports: [ CommonModule ],
|
||||||
|
providers: [ TokenSessionService ]
|
||||||
|
})
|
||||||
|
export class AccountTokenSessionsComponent implements OnInit {
|
||||||
|
private notifier = inject(Notifier)
|
||||||
|
private tokenSessionService = inject(TokenSessionService)
|
||||||
|
private confirmService = inject(ConfirmService)
|
||||||
|
|
||||||
|
readonly user = input.required<User>()
|
||||||
|
|
||||||
|
sessions: (TokenSession & { browserName: string, browserVersion: string, osName: string, osVersion: string })[] = []
|
||||||
|
|
||||||
|
ngOnInit () {
|
||||||
|
this.listSessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeSession (session: TokenSession) {
|
||||||
|
const res = await this.confirmService.confirm(
|
||||||
|
$localize`Are you sure you want to revoke this token session? The device will be logged out and will need to log in again.`,
|
||||||
|
$localize`Revoke token session`
|
||||||
|
)
|
||||||
|
if (!res) return
|
||||||
|
|
||||||
|
this.tokenSessionService.revoke({
|
||||||
|
userId: this.user().id,
|
||||||
|
sessionId: session.id
|
||||||
|
}).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.notifier.success($localize`Token session revoked`)
|
||||||
|
|
||||||
|
this.listSessions()
|
||||||
|
},
|
||||||
|
|
||||||
|
error: err => {
|
||||||
|
this.notifier.error(err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private listSessions () {
|
||||||
|
this.tokenSessionService.list({ userId: this.user().id }).subscribe({
|
||||||
|
next: ({ data }) => {
|
||||||
|
this.sessions = data.map(session => {
|
||||||
|
const uaParser = new UAParser(session.lastActivityDevice)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
|
||||||
|
browserName: uaParser.getBrowser().name,
|
||||||
|
browserVersion: uaParser.getBrowser().version,
|
||||||
|
osName: uaParser.getOS().name,
|
||||||
|
osVersion: uaParser.getOS().version
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
error: err => this.notifier.error(err.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
38
client/src/app/shared/shared-users/token-session.service.ts
Normal file
38
client/src/app/shared/shared-users/token-session.service.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { HttpClient } from '@angular/common/http'
|
||||||
|
import { Injectable, inject } from '@angular/core'
|
||||||
|
import { RestExtractor, UserService } from '@app/core'
|
||||||
|
import { ResultList, TokenSession } from '@peertube/peertube-models'
|
||||||
|
import { catchError } from 'rxjs/operators'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TokenSessionService {
|
||||||
|
private authHttp = inject(HttpClient)
|
||||||
|
private restExtractor = inject(RestExtractor)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
list (options: {
|
||||||
|
userId: number
|
||||||
|
}) {
|
||||||
|
const { userId } = options
|
||||||
|
|
||||||
|
const url = UserService.BASE_USERS_URL + userId + '/token-sessions'
|
||||||
|
|
||||||
|
return this.authHttp.get<ResultList<TokenSession>>(url)
|
||||||
|
.pipe(
|
||||||
|
catchError(err => this.restExtractor.handleError(err))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
revoke (options: {
|
||||||
|
userId: number
|
||||||
|
sessionId: number
|
||||||
|
}) {
|
||||||
|
const { userId, sessionId } = options
|
||||||
|
|
||||||
|
const url = UserService.BASE_USERS_URL + userId + '/token-sessions/' + sessionId + '/revoke'
|
||||||
|
|
||||||
|
return this.authHttp.post(url, {})
|
||||||
|
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||||
|
}
|
||||||
|
}
|
|
@ -1 +1,2 @@
|
||||||
export * from './oauth-client-local.model.js'
|
export * from './oauth-client-local.model.js'
|
||||||
|
export * from './token-session.model.js'
|
||||||
|
|
15
packages/models/src/tokens/token-session.model.ts
Normal file
15
packages/models/src/tokens/token-session.model.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
export interface TokenSession {
|
||||||
|
id: number
|
||||||
|
|
||||||
|
currentSession: boolean
|
||||||
|
|
||||||
|
loginDevice: string
|
||||||
|
loginIP: string
|
||||||
|
loginDate: Date | string
|
||||||
|
|
||||||
|
lastActivityDevice: string
|
||||||
|
lastActivityIP: string
|
||||||
|
lastActivityDate: Date | string
|
||||||
|
|
||||||
|
createdAt: Date | string
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import { HttpStatusCode, PeerTubeProblemDocument } from '@peertube/peertube-models'
|
import { pick } from '@peertube/peertube-core-utils'
|
||||||
|
import { HttpStatusCode, PeerTubeProblemDocument, ResultList, TokenSession } from '@peertube/peertube-models'
|
||||||
import { unwrapBody } from '../requests/index.js'
|
import { unwrapBody } from '../requests/index.js'
|
||||||
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
|
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
|
||||||
|
|
||||||
|
@ -6,10 +7,12 @@ type LoginOptions = OverrideCommandOptions & {
|
||||||
client?: { id?: string, secret?: string }
|
client?: { id?: string, secret?: string }
|
||||||
user?: { username: string, password?: string }
|
user?: { username: string, password?: string }
|
||||||
otpToken?: string
|
otpToken?: string
|
||||||
|
|
||||||
|
userAgent?: string
|
||||||
|
xForwardedFor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LoginCommand extends AbstractCommand {
|
export class LoginCommand extends AbstractCommand {
|
||||||
|
|
||||||
async login (options: LoginOptions = {}) {
|
async login (options: LoginOptions = {}) {
|
||||||
const res = await this._login(options)
|
const res = await this._login(options)
|
||||||
|
|
||||||
|
@ -43,10 +46,12 @@ export class LoginCommand extends AbstractCommand {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loginUsingExternalToken (options: OverrideCommandOptions & {
|
loginUsingExternalToken (
|
||||||
username: string
|
options: OverrideCommandOptions & {
|
||||||
externalAuthToken: string
|
username: string
|
||||||
}) {
|
externalAuthToken: string
|
||||||
|
}
|
||||||
|
) {
|
||||||
const { username, externalAuthToken } = options
|
const { username, externalAuthToken } = options
|
||||||
const path = '/api/v1/users/token'
|
const path = '/api/v1/users/token'
|
||||||
|
|
||||||
|
@ -71,9 +76,11 @@ export class LoginCommand extends AbstractCommand {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logout (options: OverrideCommandOptions & {
|
logout (
|
||||||
token: string
|
options: OverrideCommandOptions & {
|
||||||
}) {
|
token: string
|
||||||
|
}
|
||||||
|
) {
|
||||||
const path = '/api/v1/users/revoke-token'
|
const path = '/api/v1/users/revoke-token'
|
||||||
|
|
||||||
return unwrapBody<{ redirectUrl: string }>(this.postBodyRequest({
|
return unwrapBody<{ redirectUrl: string }>(this.postBodyRequest({
|
||||||
|
@ -86,9 +93,13 @@ export class LoginCommand extends AbstractCommand {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshToken (options: OverrideCommandOptions & {
|
refreshToken (
|
||||||
refreshToken: string
|
options: OverrideCommandOptions & {
|
||||||
}) {
|
refreshToken: string
|
||||||
|
userAgent?: string
|
||||||
|
xForwardedFor?: string
|
||||||
|
}
|
||||||
|
) {
|
||||||
const path = '/api/v1/users/token'
|
const path = '/api/v1/users/token'
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
|
@ -99,11 +110,17 @@ export class LoginCommand extends AbstractCommand {
|
||||||
grant_type: 'refresh_token'
|
grant_type: 'refresh_token'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = options.userAgent
|
||||||
|
? { 'user-agent': options.userAgent }
|
||||||
|
: {}
|
||||||
|
|
||||||
return this.postBodyRequest({
|
return this.postBodyRequest({
|
||||||
...options,
|
...options,
|
||||||
|
|
||||||
path,
|
path,
|
||||||
requestType: 'form',
|
requestType: 'form',
|
||||||
|
headers,
|
||||||
|
xForwardedFor: options.xForwardedFor,
|
||||||
fields: body,
|
fields: body,
|
||||||
implicitToken: false,
|
implicitToken: false,
|
||||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||||
|
@ -137,9 +154,9 @@ export class LoginCommand extends AbstractCommand {
|
||||||
scope: 'upload'
|
scope: 'upload'
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = otpToken
|
const headers: Record<string, string> = {}
|
||||||
? { 'x-peertube-otp': otpToken }
|
if (otpToken) headers['x-peertube-otp'] = otpToken
|
||||||
: {}
|
if (options.userAgent) headers['user-agent'] = options.userAgent
|
||||||
|
|
||||||
return this.postBodyRequest({
|
return this.postBodyRequest({
|
||||||
...options,
|
...options,
|
||||||
|
@ -147,6 +164,7 @@ export class LoginCommand extends AbstractCommand {
|
||||||
path,
|
path,
|
||||||
headers,
|
headers,
|
||||||
requestType: 'form',
|
requestType: 'form',
|
||||||
|
xForwardedFor: options.xForwardedFor,
|
||||||
fields: body,
|
fields: body,
|
||||||
implicitToken: false,
|
implicitToken: false,
|
||||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||||
|
@ -156,4 +174,43 @@ export class LoginCommand extends AbstractCommand {
|
||||||
private unwrapLoginBody (body: any) {
|
private unwrapLoginBody (body: any) {
|
||||||
return body as { access_token: string, refresh_token: string } & PeerTubeProblemDocument
|
return body as { access_token: string, refresh_token: string } & PeerTubeProblemDocument
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
listSessions (
|
||||||
|
options: OverrideCommandOptions & {
|
||||||
|
sort?: string
|
||||||
|
start?: number
|
||||||
|
count?: number
|
||||||
|
userId: number
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const path = `/api/v1/users/${options.userId}/token-sessions`
|
||||||
|
|
||||||
|
return this.getRequestBody<ResultList<TokenSession>>({
|
||||||
|
...options,
|
||||||
|
|
||||||
|
path,
|
||||||
|
query: pick(options, [ 'sort', 'start', 'count' ]),
|
||||||
|
implicitToken: true,
|
||||||
|
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeSession (
|
||||||
|
options: OverrideCommandOptions & {
|
||||||
|
userId: number
|
||||||
|
sessionId: number
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const path = `/api/v1/users/${options.userId}/token-sessions/${options.sessionId}/revoke`
|
||||||
|
|
||||||
|
return this.postBodyRequest({
|
||||||
|
...options,
|
||||||
|
|
||||||
|
path,
|
||||||
|
implicitToken: true,
|
||||||
|
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -232,13 +232,22 @@ export class UsersCommand extends AbstractCommand {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
getMyInfo (options: OverrideCommandOptions = {}) {
|
getMyInfo (options: OverrideCommandOptions & {
|
||||||
|
userAgent?: string
|
||||||
|
xForwardedFor?: string
|
||||||
|
} = {}) {
|
||||||
const path = '/api/v1/users/me'
|
const path = '/api/v1/users/me'
|
||||||
|
|
||||||
|
const headers = options.userAgent
|
||||||
|
? { 'user-agent': options.userAgent }
|
||||||
|
: {}
|
||||||
|
|
||||||
return this.getRequestBody<MyUser>({
|
return this.getRequestBody<MyUser>({
|
||||||
...options,
|
...options,
|
||||||
|
|
||||||
path,
|
path,
|
||||||
|
headers,
|
||||||
|
xForwardedFor: options.xForwardedFor,
|
||||||
implicitToken: true,
|
implicitToken: true,
|
||||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||||
})
|
})
|
||||||
|
|
|
@ -22,6 +22,7 @@ import './runners.js'
|
||||||
import './search.js'
|
import './search.js'
|
||||||
import './services.js'
|
import './services.js'
|
||||||
import './static.js'
|
import './static.js'
|
||||||
|
import './token-session.js'
|
||||||
import './transcoding.js'
|
import './transcoding.js'
|
||||||
import './two-factor.js'
|
import './two-factor.js'
|
||||||
import './upload-quota.js'
|
import './upload-quota.js'
|
||||||
|
|
|
@ -406,7 +406,7 @@ describe('Test my user API validators', function () {
|
||||||
await server.users.getMyInfo({ token: 'fake_token', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
await server.users.getMyInfo({ token: 'fake_token', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should success with the correct parameters', async function () {
|
it('Should succeed with the correct parameters', async function () {
|
||||||
await server.users.getMyInfo({ token: userToken })
|
await server.users.getMyInfo({ token: userToken })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -214,7 +214,7 @@ describe('Test server plugins API validators', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should success with the correct parameters', async function () {
|
it('Should succeed with the correct parameters', async function () {
|
||||||
await makeGetRequest({
|
await makeGetRequest({
|
||||||
url: server.url,
|
url: server.url,
|
||||||
path,
|
path,
|
||||||
|
@ -274,7 +274,7 @@ describe('Test server plugins API validators', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should success with the correct parameters', async function () {
|
it('Should succeed with the correct parameters', async function () {
|
||||||
await makeGetRequest({
|
await makeGetRequest({
|
||||||
url: server.url,
|
url: server.url,
|
||||||
path,
|
path,
|
||||||
|
|
116
packages/tests/src/api/check-params/token-session.ts
Normal file
116
packages/tests/src/api/check-params/token-session.ts
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
|
import { HttpStatusCode, User } from '@peertube/peertube-models'
|
||||||
|
import {
|
||||||
|
cleanupTests,
|
||||||
|
createSingleServer,
|
||||||
|
makeGetRequest,
|
||||||
|
PeerTubeServer,
|
||||||
|
setAccessTokensToServers
|
||||||
|
} from '@peertube/peertube-server-commands'
|
||||||
|
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
|
||||||
|
|
||||||
|
describe('Test token session API validators', function () {
|
||||||
|
let server: PeerTubeServer
|
||||||
|
let userToken1: string
|
||||||
|
let userToken2: string
|
||||||
|
let user1: User
|
||||||
|
let path: string
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
this.timeout(30000)
|
||||||
|
|
||||||
|
{
|
||||||
|
server = await createSingleServer(1)
|
||||||
|
await setAccessTokensToServers([ server ])
|
||||||
|
}
|
||||||
|
|
||||||
|
userToken1 = await server.users.generateUserAndToken('user1')
|
||||||
|
userToken2 = await server.users.generateUserAndToken('user2')
|
||||||
|
|
||||||
|
user1 = await server.users.getMyInfo({ token: userToken1 })
|
||||||
|
|
||||||
|
path = `/api/v1/users/${user1.id}/token-sessions`
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('When listing token sessions', function () {
|
||||||
|
it('Should fail with a bad start pagination', async function () {
|
||||||
|
await checkBadStartPagination(server.url, path, userToken1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with a bad count pagination', async function () {
|
||||||
|
await checkBadCountPagination(server.url, path, userToken1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an incorrect sort', async function () {
|
||||||
|
await checkBadSortPagination(server.url, path, userToken1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an unknown user', async function () {
|
||||||
|
await makeGetRequest({
|
||||||
|
url: server.url,
|
||||||
|
path: `/api/v1/users/999999999/token-sessions`,
|
||||||
|
token: userToken1,
|
||||||
|
expectedStatus: HttpStatusCode.NOT_FOUND_404
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail without a token', async function () {
|
||||||
|
await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with the token of another user', async function () {
|
||||||
|
await makeGetRequest({ url: server.url, path, token: userToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should succeed with the correct parameters', async function () {
|
||||||
|
await makeGetRequest({ url: server.url, path, token: userToken1, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('When revoking a token session', function () {
|
||||||
|
let sessionId: number
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
const response = await server.login.listSessions({ userId: user1.id, token: userToken1 })
|
||||||
|
sessionId = response.data[0].id
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail without a token', async function () {
|
||||||
|
await server.login.revokeSession({ userId: user1.id, sessionId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with the token of another user', async function () {
|
||||||
|
await server.login.revokeSession({ userId: user1.id, sessionId, token: userToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an unknown session', async function () {
|
||||||
|
await server.login.revokeSession({
|
||||||
|
userId: user1.id,
|
||||||
|
sessionId: 999999999,
|
||||||
|
token: userToken1,
|
||||||
|
expectedStatus: HttpStatusCode.NOT_FOUND_404
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an unknown user', async function () {
|
||||||
|
await server.login.revokeSession({ userId: 999999999, sessionId, token: userToken1, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with the session of another user', async function () {
|
||||||
|
const user2 = await server.users.getMyInfo({ token: userToken2 })
|
||||||
|
|
||||||
|
await server.login.revokeSession({ userId: user2.id, sessionId, token: userToken2, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should succeed with the correct parameters', async function () {
|
||||||
|
await makeGetRequest({ url: server.url, path, token: userToken1, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
await cleanupTests([ server ])
|
||||||
|
})
|
||||||
|
})
|
|
@ -93,7 +93,7 @@ describe('Test user notifications API validators', function () {
|
||||||
url: server.url,
|
url: server.url,
|
||||||
path,
|
path,
|
||||||
fields: {
|
fields: {
|
||||||
ids: [ ]
|
ids: []
|
||||||
},
|
},
|
||||||
token: server.accessToken,
|
token: server.accessToken,
|
||||||
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||||
|
@ -235,7 +235,6 @@ describe('Test user notifications API validators', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('When connecting to my notification socket', function () {
|
describe('When connecting to my notification socket', function () {
|
||||||
|
|
||||||
it('Should fail with no token', function (next) {
|
it('Should fail with no token', function (next) {
|
||||||
const socket = io(`${server.url}/user-notifications`, { reconnection: false })
|
const socket = io(`${server.url}/user-notifications`, { reconnection: false })
|
||||||
|
|
||||||
|
@ -267,7 +266,7 @@ describe('Test user notifications API validators', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should success with the correct token', function (next) {
|
it('Should succeed with the correct token', function (next) {
|
||||||
const socket = io(`${server.url}/user-notifications`, {
|
const socket = io(`${server.url}/user-notifications`, {
|
||||||
query: { accessToken: server.accessToken },
|
query: { accessToken: server.accessToken },
|
||||||
reconnection: false
|
reconnection: false
|
||||||
|
|
|
@ -50,7 +50,7 @@ describe('Test users API validators', function () {
|
||||||
await makePostBodyRequest({ url: server.url, path, fields })
|
await makePostBodyRequest({ url: server.url, path, fields })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should success with the correct params', async function () {
|
it('Should succeed with the correct params', async function () {
|
||||||
const fields = { email: 'admin@example.com' }
|
const fields = { email: 'admin@example.com' }
|
||||||
|
|
||||||
await makePostBodyRequest({
|
await makePostBodyRequest({
|
||||||
|
|
|
@ -35,7 +35,7 @@ describe('Test video captions API validator', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('When adding video caption', function () {
|
describe('When adding video caption', function () {
|
||||||
const fields = { }
|
const fields = {}
|
||||||
const attaches = {
|
const attaches = {
|
||||||
captionfile: buildAbsoluteFixturePath('subtitle-good1.vtt')
|
captionfile: buildAbsoluteFixturePath('subtitle-good1.vtt')
|
||||||
}
|
}
|
||||||
|
@ -183,7 +183,7 @@ describe('Test video captions API validator', function () {
|
||||||
// })
|
// })
|
||||||
// })
|
// })
|
||||||
|
|
||||||
it('Should success with the correct parameters', async function () {
|
it('Should succeed with the correct parameters', async function () {
|
||||||
const captionPath = path + video.uuid + '/captions/fr'
|
const captionPath = path + video.uuid + '/captions/fr'
|
||||||
await makeUploadRequest({
|
await makeUploadRequest({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
@ -227,7 +227,7 @@ describe('Test video captions API validator', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should success with the correct parameters', async function () {
|
it('Should succeed with the correct parameters', async function () {
|
||||||
await makeGetRequest({ url: server.url, path: path + video.shortUUID + '/captions', expectedStatus: HttpStatusCode.OK_200 })
|
await makeGetRequest({ url: server.url, path: path + video.shortUUID + '/captions', expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
|
||||||
await makeGetRequest({
|
await makeGetRequest({
|
||||||
|
@ -295,7 +295,7 @@ describe('Test video captions API validator', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should success with the correct parameters', async function () {
|
it('Should succeed with the correct parameters', async function () {
|
||||||
const captionPath = path + video.shortUUID + '/captions/fr'
|
const captionPath = path + video.shortUUID + '/captions/fr'
|
||||||
await makeDeleteRequest({
|
await makeDeleteRequest({
|
||||||
url: server.url,
|
url: server.url,
|
||||||
|
|
|
@ -154,7 +154,7 @@ describe('Test video comments API validator', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should success with the correct params', async function () {
|
it('Should succeed with the correct params', async function () {
|
||||||
await makeGetRequest({
|
await makeGetRequest({
|
||||||
url: server.url,
|
url: server.url,
|
||||||
token: server.accessToken,
|
token: server.accessToken,
|
||||||
|
@ -171,7 +171,6 @@ describe('Test video comments API validator', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('When adding a video thread', function () {
|
describe('When adding a video thread', function () {
|
||||||
|
|
||||||
it('Should fail with a non authenticated user', async function () {
|
it('Should fail with a non authenticated user', async function () {
|
||||||
const fields = {
|
const fields = {
|
||||||
text: 'text'
|
text: 'text'
|
||||||
|
@ -243,7 +242,6 @@ describe('Test video comments API validator', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('When adding a comment to a thread', function () {
|
describe('When adding a comment to a thread', function () {
|
||||||
|
|
||||||
it('Should fail with a non authenticated user', async function () {
|
it('Should fail with a non authenticated user', async function () {
|
||||||
const fields = {
|
const fields = {
|
||||||
text: 'text'
|
text: 'text'
|
||||||
|
@ -399,7 +397,6 @@ describe('Test video comments API validator', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('When a video has comments disabled', function () {
|
describe('When a video has comments disabled', function () {
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
video = await server.videos.upload({ attributes: { commentsPolicy: VideoCommentPolicy.DISABLED } })
|
video = await server.videos.upload({ attributes: { commentsPolicy: VideoCommentPolicy.DISABLED } })
|
||||||
pathThread = `/api/v1/videos/${video.uuid}/comment-threads`
|
pathThread = `/api/v1/videos/${video.uuid}/comment-threads`
|
||||||
|
|
|
@ -68,7 +68,7 @@ describe('Test video imports API validator', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should success with the correct parameters', async function () {
|
it('Should succeed with the correct parameters', async function () {
|
||||||
await makeGetRequest({ url: server.url, path: myPath, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken })
|
await makeGetRequest({ url: server.url, path: myPath, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -146,7 +146,7 @@ describe('Test video playlists API validator', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should success with the correct parameters', async function () {
|
it('Should succeed with the correct parameters', async function () {
|
||||||
await makeGetRequest({ url: server.url, path: globalPath, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken })
|
await makeGetRequest({ url: server.url, path: globalPath, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken })
|
||||||
await makeGetRequest({ url: server.url, path: accountPath, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken })
|
await makeGetRequest({ url: server.url, path: accountPath, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken })
|
||||||
await makeGetRequest({
|
await makeGetRequest({
|
||||||
|
@ -169,7 +169,7 @@ describe('Test video playlists API validator', function () {
|
||||||
await checkBadCountPagination(server.url, path + playlist.shortUUID + '/videos', server.accessToken)
|
await checkBadCountPagination(server.url, path + playlist.shortUUID + '/videos', server.accessToken)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should success with the correct parameters', async function () {
|
it('Should succeed with the correct parameters', async function () {
|
||||||
await makeGetRequest({ url: server.url, path: path + playlist.shortUUID + '/videos', expectedStatus: HttpStatusCode.OK_200 })
|
await makeGetRequest({ url: server.url, path: path + playlist.shortUUID + '/videos', expectedStatus: HttpStatusCode.OK_200 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -76,7 +76,7 @@ describe('Test videos API validator', function () {
|
||||||
await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.OK_200, query: { skipCount: 'toto' } })
|
await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.OK_200, query: { skipCount: 'toto' } })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should success with the correct parameters', async function () {
|
it('Should succeed with the correct parameters', async function () {
|
||||||
await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.OK_200, query: { skipCount: false } })
|
await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.OK_200, query: { skipCount: false } })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -96,7 +96,7 @@ describe('Test videos API validator', function () {
|
||||||
await checkBadSortPagination(server.url, path, undefined, { search: 'test' })
|
await checkBadSortPagination(server.url, path, undefined, { search: 'test' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should success with the correct parameters', async function () {
|
it('Should succeed with the correct parameters', async function () {
|
||||||
await makeGetRequest({ url: server.url, path, query: { search: 'test' }, expectedStatus: HttpStatusCode.OK_200 })
|
await makeGetRequest({ url: server.url, path, query: { search: 'test' }, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -165,7 +165,7 @@ describe('Test videos API validator', function () {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should success with the correct parameters', async function () {
|
it('Should succeed with the correct parameters', async function () {
|
||||||
await makeGetRequest({ url: server.url, token: server.accessToken, path, expectedStatus: HttpStatusCode.OK_200 })
|
await makeGetRequest({ url: server.url, token: server.accessToken, path, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -189,7 +189,7 @@ describe('Test videos API validator', function () {
|
||||||
await checkBadSortPagination(server.url, path, server.accessToken)
|
await checkBadSortPagination(server.url, path, server.accessToken)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should success with the correct parameters', async function () {
|
it('Should succeed with the correct parameters', async function () {
|
||||||
await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.OK_200 })
|
await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -213,7 +213,7 @@ describe('Test videos API validator', function () {
|
||||||
await checkBadSortPagination(server.url, path, server.accessToken)
|
await checkBadSortPagination(server.url, path, server.accessToken)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should success with the correct parameters', async function () {
|
it('Should succeed with the correct parameters', async function () {
|
||||||
await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.OK_200 })
|
await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
import { wait } from '@peertube/peertube-core-utils'
|
import { wait } from '@peertube/peertube-core-utils'
|
||||||
import { HttpStatusCode, OAuth2ErrorCode, PeerTubeProblemDocument } from '@peertube/peertube-models'
|
import { HttpStatusCode, MyUser, OAuth2ErrorCode, PeerTubeProblemDocument } from '@peertube/peertube-models'
|
||||||
import {
|
import {
|
||||||
cleanupTests,
|
cleanupTests,
|
||||||
createSingleServer,
|
createSingleServer,
|
||||||
|
@ -38,7 +38,6 @@ describe('Test oauth', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('OAuth client', function () {
|
describe('OAuth client', function () {
|
||||||
|
|
||||||
function expectInvalidClient (body: PeerTubeProblemDocument) {
|
function expectInvalidClient (body: PeerTubeProblemDocument) {
|
||||||
expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
|
expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
|
||||||
expect(body.detail).to.contain('client is invalid')
|
expect(body.detail).to.contain('client is invalid')
|
||||||
|
@ -68,7 +67,6 @@ describe('Test oauth', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Login', function () {
|
describe('Login', function () {
|
||||||
|
|
||||||
function expectInvalidCredentials (body: PeerTubeProblemDocument) {
|
function expectInvalidCredentials (body: PeerTubeProblemDocument) {
|
||||||
expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
|
expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
|
||||||
expect(body.detail).to.contain('credentials are invalid')
|
expect(body.detail).to.contain('credentials are invalid')
|
||||||
|
@ -126,7 +124,6 @@ describe('Test oauth', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Logout', function () {
|
describe('Logout', function () {
|
||||||
|
|
||||||
it('Should logout (revoke token)', async function () {
|
it('Should logout (revoke token)', async function () {
|
||||||
await server.login.logout({ token: server.accessToken })
|
await server.login.logout({ token: server.accessToken })
|
||||||
})
|
})
|
||||||
|
@ -184,6 +181,222 @@ describe('Test oauth', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Token sessions', function () {
|
||||||
|
let user10: MyUser
|
||||||
|
let user10Token: string
|
||||||
|
|
||||||
|
let user20: MyUser
|
||||||
|
let user20Token: string
|
||||||
|
let user20RefreshToken: string
|
||||||
|
|
||||||
|
const beforeAllDate = new Date().getTime()
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
this.timeout(120_000)
|
||||||
|
|
||||||
|
{
|
||||||
|
await server.users.create({ username: 'user10', password: 'password' })
|
||||||
|
|
||||||
|
const res = await server.login.login({
|
||||||
|
user: { username: 'user10', password: 'password' },
|
||||||
|
userAgent: 'web',
|
||||||
|
xForwardedFor: '0.0.0.42,127.0.0.1'
|
||||||
|
})
|
||||||
|
|
||||||
|
user10Token = res.access_token
|
||||||
|
user10 = await server.users.getMyInfo({ token: user10Token })
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await server.users.create({ username: 'user20', password: 'password' })
|
||||||
|
|
||||||
|
const res = await server.login.login({
|
||||||
|
user: { username: 'user20', password: 'password' }
|
||||||
|
})
|
||||||
|
|
||||||
|
user20Token = res.access_token
|
||||||
|
user20RefreshToken = res.refresh_token
|
||||||
|
user20 = await server.users.getMyInfo({ token: user20Token })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should create multiple token sessions', async function () {
|
||||||
|
await server.login.getAccessToken({ username: 'user10', password: 'password' })
|
||||||
|
await server.login.getAccessToken({ username: 'user10', password: 'password' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should list sessions of a user', async function () {
|
||||||
|
{
|
||||||
|
const { data, total } = await server.login.listSessions({ userId: user20.id })
|
||||||
|
expect(total).to.equal(1)
|
||||||
|
expect(data.length).to.equal(1)
|
||||||
|
|
||||||
|
const session = data[0]
|
||||||
|
expect(session.currentSession).to.be.false
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { data, total } = await server.login.listSessions({ userId: user20.id, token: user20Token })
|
||||||
|
expect(total).to.equal(1)
|
||||||
|
expect(data.length).to.equal(1)
|
||||||
|
|
||||||
|
const session = data[0]
|
||||||
|
expect(session.currentSession).to.be.true
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { data, total } = await server.login.listSessions({ userId: user10.id, token: user10Token, sort: 'createdAt' })
|
||||||
|
expect(total).to.equal(3)
|
||||||
|
expect(data.length).to.equal(3)
|
||||||
|
|
||||||
|
const session = data[0]
|
||||||
|
expect(session.currentSession).to.be.true
|
||||||
|
|
||||||
|
expect(new Date(session.lastActivityDate).getTime()).to.be.above(beforeAllDate)
|
||||||
|
expect(new Date(session.createdAt).getTime()).to.be.above(beforeAllDate)
|
||||||
|
expect(new Date(session.lastActivityDate).getTime()).to.equal(new Date(session.loginDate).getTime())
|
||||||
|
|
||||||
|
expect(session.loginIP).to.equal('0.0.0.42')
|
||||||
|
expect(session.lastActivityIP).to.equal(session.loginIP)
|
||||||
|
|
||||||
|
expect(session.loginDevice).to.equal('web')
|
||||||
|
expect(session.lastActivityDevice).to.equal(session.loginDevice)
|
||||||
|
|
||||||
|
expect(data[1].currentSession).to.be.false
|
||||||
|
expect(data[2].currentSession).to.be.false
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { data, total } = await server.login.listSessions({
|
||||||
|
userId: user10.id,
|
||||||
|
token: user10Token,
|
||||||
|
sort: '-createdAt',
|
||||||
|
start: 0,
|
||||||
|
count: 1
|
||||||
|
})
|
||||||
|
expect(total).to.equal(3)
|
||||||
|
expect(data.length).to.equal(1)
|
||||||
|
expect(data[0].currentSession).to.be.false
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { data, total } = await server.login.listSessions({
|
||||||
|
userId: user10.id,
|
||||||
|
token: user10Token,
|
||||||
|
sort: '-createdAt',
|
||||||
|
start: 1,
|
||||||
|
count: 2
|
||||||
|
})
|
||||||
|
expect(total).to.equal(3)
|
||||||
|
expect(data.length).to.equal(2)
|
||||||
|
expect(data[0].currentSession).to.be.false
|
||||||
|
expect(data[1].currentSession).to.be.true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should refresh a token session and have appropriate metadata', async function () {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const { body } = await server.login.refreshToken({
|
||||||
|
refreshToken: user20RefreshToken,
|
||||||
|
userAgent: 'user agent 2',
|
||||||
|
xForwardedFor: '0.0.0.1,127.0.0.1'
|
||||||
|
})
|
||||||
|
const newAccessToken = body.access_token
|
||||||
|
|
||||||
|
{
|
||||||
|
const { data, total } = await server.login.listSessions({ userId: user20.id, token: newAccessToken })
|
||||||
|
expect(total).to.equal(1)
|
||||||
|
expect(data.length).to.equal(1)
|
||||||
|
|
||||||
|
const session = data[0]
|
||||||
|
|
||||||
|
expect(session.currentSession).to.be.true
|
||||||
|
|
||||||
|
expect(new Date(session.loginDate).getTime()).to.be.below(now.getTime())
|
||||||
|
expect(new Date(session.lastActivityDate).getTime()).to.be.above(now.getTime())
|
||||||
|
|
||||||
|
expect(session.loginDevice).to.not.equal(session.lastActivityDevice)
|
||||||
|
expect(session.loginIP).to.not.equal(session.lastActivityIP)
|
||||||
|
|
||||||
|
expect(session.lastActivityDevice).to.equal('user agent 2')
|
||||||
|
expect(session.lastActivityIP).to.equal('0.0.0.1')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should update last activity of a session', async function () {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
await server.users.getMyInfo({ token: user10Token, userAgent: 'web 2', xForwardedFor: '0.0.0.43,127.0.0.1' })
|
||||||
|
await wait(3000)
|
||||||
|
|
||||||
|
{
|
||||||
|
const { data, total } = await server.login.listSessions({ userId: user10.id, token: user10Token, sort: 'createdAt' })
|
||||||
|
expect(total).to.equal(3)
|
||||||
|
expect(data.length).to.equal(3)
|
||||||
|
|
||||||
|
const session = data[0]
|
||||||
|
expect(session.currentSession).to.be.true
|
||||||
|
|
||||||
|
expect(new Date(session.lastActivityDate).getTime()).to.be.above(now.getTime())
|
||||||
|
expect(new Date(session.loginDate).getTime()).to.be.below(now.getTime())
|
||||||
|
|
||||||
|
expect(session.loginIP).to.equal('0.0.0.42')
|
||||||
|
expect(session.lastActivityIP).to.equal('0.0.0.43')
|
||||||
|
|
||||||
|
expect(session.loginDevice).to.equal('web')
|
||||||
|
expect(session.lastActivityDevice).to.equal('web 2')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should update last activity of a session even after a server restart', async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
await server.kill()
|
||||||
|
await server.run()
|
||||||
|
|
||||||
|
{
|
||||||
|
const { data } = await server.login.listSessions({ userId: user10.id, token: user10Token, sort: 'createdAt' })
|
||||||
|
const session = data[0]
|
||||||
|
expect(session.currentSession).to.be.true
|
||||||
|
|
||||||
|
expect(session.lastActivityIP).to.not.equal('0.0.0.42')
|
||||||
|
expect(session.lastActivityDevice).to.not.equal('web')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should revoke a token session', async function () {
|
||||||
|
const token4 = await server.login.getAccessToken({ username: 'user10', password: 'password' })
|
||||||
|
await server.users.getMyInfo({ token: token4 })
|
||||||
|
|
||||||
|
const { data } = await server.login.listSessions({ userId: user10.id, token: user10Token, sort: '-createdAt' })
|
||||||
|
const tokenSession4 = data[0]
|
||||||
|
|
||||||
|
await server.login.revokeSession({
|
||||||
|
sessionId: tokenSession4.id,
|
||||||
|
userId: user10.id,
|
||||||
|
token: user10Token
|
||||||
|
})
|
||||||
|
|
||||||
|
await server.users.getMyInfo({ token: token4, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should revoke a token session of another user', async function () {
|
||||||
|
const token5 = await server.login.getAccessToken({ username: 'user10', password: 'password' })
|
||||||
|
await server.users.getMyInfo({ token: token5 })
|
||||||
|
|
||||||
|
const { data } = await server.login.listSessions({ userId: user10.id, token: user10Token, sort: '-createdAt' })
|
||||||
|
const tokenSession4 = data[0]
|
||||||
|
|
||||||
|
await server.login.revokeSession({
|
||||||
|
sessionId: tokenSession4.id,
|
||||||
|
userId: user10.id
|
||||||
|
})
|
||||||
|
|
||||||
|
await server.users.getMyInfo({ token: token5, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Custom token lifetime', function () {
|
describe('Custom token lifetime', function () {
|
||||||
before(async function () {
|
before(async function () {
|
||||||
this.timeout(120_000)
|
this.timeout(120_000)
|
||||||
|
@ -219,7 +432,6 @@ describe('Test oauth', function () {
|
||||||
await server.login.refreshToken({ refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
await server.login.refreshToken({ refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
await sqlCommand.cleanup()
|
await sqlCommand.cleanup()
|
||||||
await cleanupTests([ server ])
|
await cleanupTests([ server ])
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import express from 'express'
|
import { ResultList, ScopedToken, TokenSession } from '@peertube/peertube-models'
|
||||||
import { ScopedToken } from '@peertube/peertube-models'
|
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||||
import { logger } from '@server/helpers/logger.js'
|
import { logger } from '@server/helpers/logger.js'
|
||||||
import { CONFIG } from '@server/initializers/config.js'
|
import { CONFIG } from '@server/initializers/config.js'
|
||||||
import { OTP } from '@server/initializers/constants.js'
|
import { OTP } from '@server/initializers/constants.js'
|
||||||
|
@ -7,8 +7,19 @@ import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPa
|
||||||
import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model.js'
|
import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model.js'
|
||||||
import { handleOAuthToken, MissingTwoFactorError } from '@server/lib/auth/oauth.js'
|
import { handleOAuthToken, MissingTwoFactorError } from '@server/lib/auth/oauth.js'
|
||||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||||
import { asyncMiddleware, authenticate, buildRateLimiter, openapiOperationDoc } from '@server/middlewares/index.js'
|
import {
|
||||||
import { buildUUID } from '@peertube/peertube-node-utils'
|
asyncMiddleware,
|
||||||
|
authenticate,
|
||||||
|
buildRateLimiter,
|
||||||
|
openapiOperationDoc,
|
||||||
|
paginationValidator,
|
||||||
|
setDefaultPagination,
|
||||||
|
setDefaultSort,
|
||||||
|
tokenSessionsSortValidator
|
||||||
|
} from '@server/middlewares/index.js'
|
||||||
|
import { manageTokenSessionsValidator, revokeTokenSessionValidator } from '@server/middlewares/validators/token.js'
|
||||||
|
import { OAuthTokenModel } from '@server/models/oauth/oauth-token.js'
|
||||||
|
import express from 'express'
|
||||||
|
|
||||||
const tokensRouter = express.Router()
|
const tokensRouter = express.Router()
|
||||||
|
|
||||||
|
@ -17,24 +28,51 @@ const loginRateLimiter = buildRateLimiter({
|
||||||
max: CONFIG.RATES_LIMIT.LOGIN.MAX
|
max: CONFIG.RATES_LIMIT.LOGIN.MAX
|
||||||
})
|
})
|
||||||
|
|
||||||
tokensRouter.post('/token',
|
tokensRouter.post(
|
||||||
|
'/token',
|
||||||
loginRateLimiter,
|
loginRateLimiter,
|
||||||
openapiOperationDoc({ operationId: 'getOAuthToken' }),
|
openapiOperationDoc({ operationId: 'getOAuthToken' }),
|
||||||
asyncMiddleware(handleToken)
|
asyncMiddleware(handleToken)
|
||||||
)
|
)
|
||||||
|
|
||||||
tokensRouter.post('/revoke-token',
|
tokensRouter.post(
|
||||||
|
'/revoke-token',
|
||||||
openapiOperationDoc({ operationId: 'revokeOAuthToken' }),
|
openapiOperationDoc({ operationId: 'revokeOAuthToken' }),
|
||||||
authenticate,
|
authenticate,
|
||||||
asyncMiddleware(handleTokenRevocation)
|
asyncMiddleware(handleTokenRevocation)
|
||||||
)
|
)
|
||||||
|
|
||||||
tokensRouter.get('/scoped-tokens',
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
tokensRouter.get(
|
||||||
|
'/:userId/token-sessions',
|
||||||
|
authenticate,
|
||||||
|
asyncMiddleware(manageTokenSessionsValidator),
|
||||||
|
paginationValidator,
|
||||||
|
tokenSessionsSortValidator,
|
||||||
|
setDefaultSort,
|
||||||
|
setDefaultPagination,
|
||||||
|
asyncMiddleware(listTokenSessions)
|
||||||
|
)
|
||||||
|
|
||||||
|
tokensRouter.post(
|
||||||
|
'/:userId/token-sessions/:tokenSessionId/revoke',
|
||||||
|
authenticate,
|
||||||
|
asyncMiddleware(manageTokenSessionsValidator),
|
||||||
|
asyncMiddleware(revokeTokenSessionValidator),
|
||||||
|
asyncMiddleware(revokeTokenSession)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
tokensRouter.get(
|
||||||
|
'/scoped-tokens',
|
||||||
authenticate,
|
authenticate,
|
||||||
getScopedTokens
|
getScopedTokens
|
||||||
)
|
)
|
||||||
|
|
||||||
tokensRouter.post('/scoped-tokens',
|
tokensRouter.post(
|
||||||
|
'/scoped-tokens',
|
||||||
authenticate,
|
authenticate,
|
||||||
asyncMiddleware(renewScopedTokens)
|
asyncMiddleware(renewScopedTokens)
|
||||||
)
|
)
|
||||||
|
@ -101,6 +139,36 @@ async function handleTokenRevocation (req: express.Request, res: express.Respons
|
||||||
return res.json(result)
|
return res.json(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function listTokenSessions (req: express.Request, res: express.Response) {
|
||||||
|
const currentToken = res.locals.oauth.token
|
||||||
|
|
||||||
|
const { total, data } = await OAuthTokenModel.listSessionsOf({
|
||||||
|
start: req.query.start as number,
|
||||||
|
count: req.query.count as number,
|
||||||
|
sort: req.query.sort as string,
|
||||||
|
userId: res.locals.user.id
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json(
|
||||||
|
{
|
||||||
|
total,
|
||||||
|
data: data.map(session => session.toSessionFormattedJSON(currentToken.accessToken))
|
||||||
|
} satisfies ResultList<TokenSession>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeTokenSession (req: express.Request, res: express.Response) {
|
||||||
|
const token = res.locals.tokenSession
|
||||||
|
|
||||||
|
const result = await revokeToken(token, { req, explicitLogout: true })
|
||||||
|
|
||||||
|
return res.json(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function getScopedTokens (req: express.Request, res: express.Response) {
|
function getScopedTokens (req: express.Request, res: express.Response) {
|
||||||
const user = res.locals.oauth.token.user
|
const user = res.locals.oauth.token.user
|
||||||
|
|
||||||
|
|
|
@ -48,7 +48,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const LAST_MIGRATION_VERSION = 915
|
export const LAST_MIGRATION_VERSION = 920
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ -109,6 +109,8 @@ export const SORTABLE_COLUMNS = {
|
||||||
|
|
||||||
USER_REGISTRATIONS: [ 'createdAt', 'state' ],
|
USER_REGISTRATIONS: [ 'createdAt', 'state' ],
|
||||||
|
|
||||||
|
TOKEN_SESSIONS: [ 'createdAt' ],
|
||||||
|
|
||||||
RUNNERS: [ 'createdAt' ],
|
RUNNERS: [ 'createdAt' ],
|
||||||
RUNNER_REGISTRATION_TOKENS: [ 'createdAt' ],
|
RUNNER_REGISTRATION_TOKENS: [ 'createdAt' ],
|
||||||
RUNNER_JOBS: [ 'updatedAt', 'createdAt', 'priority', 'state', 'progress' ],
|
RUNNER_JOBS: [ 'updatedAt', 'createdAt', 'priority', 'state', 'progress' ],
|
||||||
|
@ -339,6 +341,7 @@ export const SCHEDULER_INTERVALS_MS = {
|
||||||
ACTOR_FOLLOW_SCORES: 60000 * 60, // 1 hour
|
ACTOR_FOLLOW_SCORES: 60000 * 60, // 1 hour
|
||||||
REMOVE_OLD_JOBS: 60000 * 60, // 1 hour
|
REMOVE_OLD_JOBS: 60000 * 60, // 1 hour
|
||||||
UPDATE_VIDEOS: 60000, // 1 minute
|
UPDATE_VIDEOS: 60000, // 1 minute
|
||||||
|
UPDATE_TOKEN_SESSION: 60000, // 1 minute
|
||||||
YOUTUBE_DL_UPDATE: 60000 * 60 * 24, // 1 day
|
YOUTUBE_DL_UPDATE: 60000 * 60 * 24, // 1 day
|
||||||
GEO_IP_UPDATE: 60000 * 60 * 24, // 1 day
|
GEO_IP_UPDATE: 60000 * 60 * 24, // 1 day
|
||||||
VIDEO_VIEWS_BUFFER_UPDATE: CONFIG.VIEWS.VIDEOS.LOCAL_BUFFER_UPDATE_INTERVAL,
|
VIDEO_VIEWS_BUFFER_UPDATE: CONFIG.VIEWS.VIDEOS.LOCAL_BUFFER_UPDATE_INTERVAL,
|
||||||
|
@ -1208,6 +1211,7 @@ if (process.env.PRODUCTION_CONSTANTS !== 'true') {
|
||||||
SCHEDULER_INTERVALS_MS.AUTO_FOLLOW_INDEX_INSTANCES = 5000
|
SCHEDULER_INTERVALS_MS.AUTO_FOLLOW_INDEX_INSTANCES = 5000
|
||||||
SCHEDULER_INTERVALS_MS.UPDATE_INBOX_STATS = 5000
|
SCHEDULER_INTERVALS_MS.UPDATE_INBOX_STATS = 5000
|
||||||
SCHEDULER_INTERVALS_MS.CHECK_PEERTUBE_VERSION = 2000
|
SCHEDULER_INTERVALS_MS.CHECK_PEERTUBE_VERSION = 2000
|
||||||
|
SCHEDULER_INTERVALS_MS.UPDATE_TOKEN_SESSION = 2000
|
||||||
|
|
||||||
REPEAT_JOBS['videos-views-stats'] = { every: 5000 }
|
REPEAT_JOBS['videos-views-stats'] = { every: 5000 }
|
||||||
|
|
||||||
|
|
46
server/core/initializers/migrations/0920-token-sessions.ts
Normal file
46
server/core/initializers/migrations/0920-token-sessions.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import * as Sequelize from 'sequelize'
|
||||||
|
|
||||||
|
async function up (utils: {
|
||||||
|
transaction: Sequelize.Transaction
|
||||||
|
queryInterface: Sequelize.QueryInterface
|
||||||
|
sequelize: Sequelize.Sequelize
|
||||||
|
}): Promise<void> {
|
||||||
|
const { transaction } = utils
|
||||||
|
|
||||||
|
const stringColumns = [
|
||||||
|
'loginDevice',
|
||||||
|
'loginIP',
|
||||||
|
'lastActivityDevice',
|
||||||
|
'lastActivityIP'
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const c of stringColumns) {
|
||||||
|
await utils.queryInterface.addColumn('oAuthToken', c, {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
defaultValue: null,
|
||||||
|
allowNull: true
|
||||||
|
}, { transaction })
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateColumns = [
|
||||||
|
'loginDate',
|
||||||
|
'lastActivityDate'
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const c of dateColumns) {
|
||||||
|
await utils.queryInterface.addColumn('oAuthToken', c, {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
defaultValue: null,
|
||||||
|
allowNull: true
|
||||||
|
}, { transaction })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function down (options) {
|
||||||
|
throw new Error('Not implemented.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
down,
|
||||||
|
up
|
||||||
|
}
|
|
@ -23,6 +23,12 @@ type TokenInfo = {
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
accessTokenExpiresAt: Date
|
accessTokenExpiresAt: Date
|
||||||
refreshTokenExpiresAt: Date
|
refreshTokenExpiresAt: Date
|
||||||
|
loginDevice: string
|
||||||
|
loginIP: string
|
||||||
|
loginDate: Date
|
||||||
|
lastActivityDevice: string
|
||||||
|
lastActivityIP: string
|
||||||
|
lastActivityDate: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BypassLogin = {
|
export type BypassLogin = {
|
||||||
|
@ -194,13 +200,21 @@ async function saveToken (
|
||||||
authName = refreshTokenAuthName
|
authName = refreshTokenAuthName
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('Saving token ' + token.accessToken + ' for client ' + client.id + ' and user ' + user.id + '.')
|
logger.debug(`Saving token ${token.accessToken} for client ${client.id} and user ${user.id}.`)
|
||||||
|
|
||||||
const tokenToCreate = {
|
const tokenToCreate = {
|
||||||
accessToken: token.accessToken,
|
...pick(token, [
|
||||||
accessTokenExpiresAt: token.accessTokenExpiresAt,
|
'accessToken',
|
||||||
refreshToken: token.refreshToken,
|
'refreshToken',
|
||||||
refreshTokenExpiresAt: token.refreshTokenExpiresAt,
|
'accessTokenExpiresAt',
|
||||||
|
'refreshTokenExpiresAt',
|
||||||
|
'loginDevice',
|
||||||
|
'loginIP',
|
||||||
|
'loginDate',
|
||||||
|
'lastActivityDate',
|
||||||
|
'lastActivityDevice',
|
||||||
|
'lastActivityIP'
|
||||||
|
]),
|
||||||
authName,
|
authName,
|
||||||
oAuthClientId: client.id,
|
oAuthClientId: client.id,
|
||||||
userId: user.id
|
userId: user.id
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import express from 'express'
|
|
||||||
import OAuth2Server, {
|
import OAuth2Server, {
|
||||||
InvalidClientError,
|
InvalidClientError,
|
||||||
InvalidGrantError,
|
InvalidGrantError,
|
||||||
|
@ -8,16 +7,18 @@ import OAuth2Server, {
|
||||||
UnauthorizedClientError,
|
UnauthorizedClientError,
|
||||||
UnsupportedGrantTypeError
|
UnsupportedGrantTypeError
|
||||||
} from '@node-oauth/oauth2-server'
|
} from '@node-oauth/oauth2-server'
|
||||||
|
import { pick } from '@peertube/peertube-core-utils'
|
||||||
|
import { HttpStatusCode, ServerErrorCode, UserRegistrationState } from '@peertube/peertube-models'
|
||||||
|
import { sha1 } from '@peertube/peertube-node-utils'
|
||||||
import { randomBytesPromise } from '@server/helpers/core-utils.js'
|
import { randomBytesPromise } from '@server/helpers/core-utils.js'
|
||||||
import { isOTPValid } from '@server/helpers/otp.js'
|
import { isOTPValid } from '@server/helpers/otp.js'
|
||||||
import { CONFIG } from '@server/initializers/config.js'
|
import { CONFIG } from '@server/initializers/config.js'
|
||||||
import { UserRegistrationModel } from '@server/models/user/user-registration.js'
|
import { UserRegistrationModel } from '@server/models/user/user-registration.js'
|
||||||
import { MOAuthClient } from '@server/types/models/index.js'
|
import { MOAuthClient } from '@server/types/models/index.js'
|
||||||
import { sha1 } from '@peertube/peertube-node-utils'
|
import express from 'express'
|
||||||
import { HttpStatusCode, ServerErrorCode, UserRegistrationState } from '@peertube/peertube-models'
|
|
||||||
import { OTP } from '../../initializers/constants.js'
|
import { OTP } from '../../initializers/constants.js'
|
||||||
import { BypassLogin, getAccessToken, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model.js'
|
|
||||||
import { Hooks } from '../plugins/hooks.js'
|
import { Hooks } from '../plugins/hooks.js'
|
||||||
|
import { BypassLogin, getAccessToken, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model.js'
|
||||||
|
|
||||||
class MissingTwoFactorError extends Error {
|
class MissingTwoFactorError extends Error {
|
||||||
code = HttpStatusCode.UNAUTHORIZED_401
|
code = HttpStatusCode.UNAUTHORIZED_401
|
||||||
|
@ -40,9 +41,7 @@ class RegistrationApprovalRejected extends Error {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
|
||||||
* Reimplement some functions of OAuth2Server to inject external auth methods
|
* Reimplement some functions of OAuth2Server to inject external auth methods
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
const oAuthServer = new OAuth2Server({
|
const oAuthServer = new OAuth2Server({
|
||||||
// Wants seconds
|
// Wants seconds
|
||||||
|
@ -99,18 +98,25 @@ async function handleOAuthToken (req: express.Request, options: { refreshTokenAu
|
||||||
throw new UnauthorizedClientError('Unauthorized client: `grant_type` is invalid')
|
throw new UnauthorizedClientError('Unauthorized client: `grant_type` is invalid')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ip = req.ip
|
||||||
|
const userAgent = req.headers['user-agent']
|
||||||
|
|
||||||
if (grantType === 'password') {
|
if (grantType === 'password') {
|
||||||
return handlePasswordGrant({
|
return handlePasswordGrant({
|
||||||
request,
|
request,
|
||||||
client,
|
client,
|
||||||
bypassLogin
|
bypassLogin,
|
||||||
|
ip,
|
||||||
|
userAgent
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleRefreshGrant({
|
return handleRefreshGrant({
|
||||||
request,
|
request,
|
||||||
client,
|
client,
|
||||||
refreshTokenAuthName
|
refreshTokenAuthName,
|
||||||
|
ip,
|
||||||
|
userAgent
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -122,11 +128,10 @@ function handleOAuthAuthenticate (
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
MissingTwoFactorError,
|
handleOAuthAuthenticate,
|
||||||
InvalidTwoFactorError,
|
|
||||||
|
|
||||||
handleOAuthToken,
|
handleOAuthToken,
|
||||||
handleOAuthAuthenticate
|
InvalidTwoFactorError,
|
||||||
|
MissingTwoFactorError
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
@ -135,6 +140,8 @@ async function handlePasswordGrant (options: {
|
||||||
request: Request
|
request: Request
|
||||||
client: MOAuthClient
|
client: MOAuthClient
|
||||||
bypassLogin?: BypassLogin
|
bypassLogin?: BypassLogin
|
||||||
|
ip: string
|
||||||
|
userAgent: string
|
||||||
}) {
|
}) {
|
||||||
const { client } = options
|
const { client } = options
|
||||||
|
|
||||||
|
@ -177,7 +184,16 @@ async function handlePasswordGrant (options: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await buildToken()
|
const now = new Date()
|
||||||
|
|
||||||
|
const token = await buildToken({
|
||||||
|
loginDevice: options.userAgent,
|
||||||
|
loginIP: options.ip,
|
||||||
|
loginDate: now,
|
||||||
|
lastActivityDevice: options.userAgent,
|
||||||
|
lastActivityIP: options.ip,
|
||||||
|
lastActivityDate: now
|
||||||
|
})
|
||||||
|
|
||||||
return saveToken(token, client, user, { bypassLogin })
|
return saveToken(token, client, user, { bypassLogin })
|
||||||
}
|
}
|
||||||
|
@ -186,6 +202,8 @@ async function handleRefreshGrant (options: {
|
||||||
request: Request
|
request: Request
|
||||||
client: MOAuthClient
|
client: MOAuthClient
|
||||||
refreshTokenAuthName: string
|
refreshTokenAuthName: string
|
||||||
|
ip: string
|
||||||
|
userAgent: string
|
||||||
}) {
|
}) {
|
||||||
const { request, client, refreshTokenAuthName } = options
|
const { request, client, refreshTokenAuthName } = options
|
||||||
|
|
||||||
|
@ -209,7 +227,17 @@ async function handleRefreshGrant (options: {
|
||||||
|
|
||||||
await revokeToken({ refreshToken: refreshToken.refreshToken })
|
await revokeToken({ refreshToken: refreshToken.refreshToken })
|
||||||
|
|
||||||
const token = await buildToken()
|
const token = await buildToken({
|
||||||
|
lastActivityDevice: options.userAgent,
|
||||||
|
lastActivityIP: options.ip,
|
||||||
|
lastActivityDate: new Date(),
|
||||||
|
|
||||||
|
...pick(refreshToken.token, [
|
||||||
|
'loginDevice',
|
||||||
|
'loginIP',
|
||||||
|
'loginDate'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
return saveToken(token, client, refreshToken.user, { refreshTokenAuthName })
|
return saveToken(token, client, refreshToken.user, { refreshTokenAuthName })
|
||||||
}
|
}
|
||||||
|
@ -227,13 +255,29 @@ function getTokenExpiresAt (type: 'access' | 'refresh') {
|
||||||
return new Date(Date.now() + lifetime)
|
return new Date(Date.now() + lifetime)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildToken () {
|
async function buildToken (options: {
|
||||||
|
loginDevice: string
|
||||||
|
loginIP: string
|
||||||
|
loginDate: Date
|
||||||
|
lastActivityDevice: string
|
||||||
|
lastActivityIP: string
|
||||||
|
lastActivityDate: Date
|
||||||
|
}) {
|
||||||
const [ accessToken, refreshToken ] = await Promise.all([ generateRandomToken(), generateRandomToken() ])
|
const [ accessToken, refreshToken ] = await Promise.all([ generateRandomToken(), generateRandomToken() ])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
accessTokenExpiresAt: getTokenExpiresAt('access'),
|
accessTokenExpiresAt: getTokenExpiresAt('access'),
|
||||||
refreshTokenExpiresAt: getTokenExpiresAt('refresh')
|
refreshTokenExpiresAt: getTokenExpiresAt('refresh'),
|
||||||
|
|
||||||
|
...pick(options, [
|
||||||
|
'loginDevice',
|
||||||
|
'loginIP',
|
||||||
|
'loginDate',
|
||||||
|
'lastActivityDevice',
|
||||||
|
'lastActivityIP',
|
||||||
|
'lastActivityDate'
|
||||||
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
32
server/core/lib/schedulers/update-token-session-scheduler.ts
Normal file
32
server/core/lib/schedulers/update-token-session-scheduler.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { MOAuthToken } from '@server/types/models/index.js'
|
||||||
|
import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants.js'
|
||||||
|
import { AbstractScheduler } from './abstract-scheduler.js'
|
||||||
|
|
||||||
|
export class UpdateTokenSessionScheduler extends AbstractScheduler {
|
||||||
|
private static instance: UpdateTokenSessionScheduler
|
||||||
|
|
||||||
|
protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.UPDATE_TOKEN_SESSION
|
||||||
|
|
||||||
|
private readonly toUpdate = new Set<MOAuthToken>()
|
||||||
|
|
||||||
|
private constructor () {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
addToUpdate (token: MOAuthToken) {
|
||||||
|
this.toUpdate.add(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async internalExecute () {
|
||||||
|
const toUpdate = Array.from(this.toUpdate)
|
||||||
|
this.toUpdate.clear()
|
||||||
|
|
||||||
|
for (const token of toUpdate) {
|
||||||
|
await token.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static get Instance () {
|
||||||
|
return this.instance || (this.instance = new this())
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,17 +1,24 @@
|
||||||
import express from 'express'
|
|
||||||
import { Socket } from 'socket.io'
|
|
||||||
import { HttpStatusCode, HttpStatusCodeType, ServerErrorCodeType } from '@peertube/peertube-models'
|
import { HttpStatusCode, HttpStatusCodeType, ServerErrorCodeType } from '@peertube/peertube-models'
|
||||||
import { getAccessToken } from '@server/lib/auth/oauth-model.js'
|
import { getAccessToken } from '@server/lib/auth/oauth-model.js'
|
||||||
import { RunnerModel } from '@server/models/runner/runner.js'
|
import { RunnerModel } from '@server/models/runner/runner.js'
|
||||||
|
import express from 'express'
|
||||||
|
import { Socket } from 'socket.io'
|
||||||
import { logger } from '../helpers/logger.js'
|
import { logger } from '../helpers/logger.js'
|
||||||
import { handleOAuthAuthenticate } from '../lib/auth/oauth.js'
|
import { handleOAuthAuthenticate } from '../lib/auth/oauth.js'
|
||||||
|
import { UpdateTokenSessionScheduler } from '@server/lib/schedulers/update-token-session-scheduler.js'
|
||||||
|
|
||||||
function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) {
|
export function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||||
handleOAuthAuthenticate(req, res)
|
handleOAuthAuthenticate(req, res)
|
||||||
.then((token: any) => {
|
.then((token: any) => {
|
||||||
res.locals.oauth = { token }
|
res.locals.oauth = { token }
|
||||||
res.locals.authenticated = true
|
res.locals.authenticated = true
|
||||||
|
|
||||||
|
token.lastActivityDate = new Date()
|
||||||
|
token.lastActivityIP = req.ip
|
||||||
|
token.lastActivityDevice = req.header('user-agent')
|
||||||
|
|
||||||
|
UpdateTokenSessionScheduler.Instance.addToUpdate(token)
|
||||||
|
|
||||||
return next()
|
return next()
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
@ -25,7 +32,7 @@ function authenticate (req: express.Request, res: express.Response, next: expres
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function authenticateSocket (socket: Socket, next: (err?: any) => void) {
|
export function authenticateSocket (socket: Socket, next: (err?: any) => void) {
|
||||||
const accessToken = socket.handshake.query['accessToken']
|
const accessToken = socket.handshake.query['accessToken']
|
||||||
|
|
||||||
logger.debug('Checking access token in runner.')
|
logger.debug('Checking access token in runner.')
|
||||||
|
@ -48,7 +55,7 @@ function authenticateSocket (socket: Socket, next: (err?: any) => void) {
|
||||||
.catch(err => logger.error('Cannot get access token.', { err }))
|
.catch(err => logger.error('Cannot get access token.', { err }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function authenticatePromise (options: {
|
export function authenticatePromise (options: {
|
||||||
req: express.Request
|
req: express.Request
|
||||||
res: express.Response
|
res: express.Response
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
|
@ -72,7 +79,7 @@ function authenticatePromise (options: {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function optionalAuthenticate (req: express.Request, res: express.Response, next: express.NextFunction) {
|
export function optionalAuthenticate (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||||
if (req.header('authorization')) return authenticate(req, res, next)
|
if (req.header('authorization')) return authenticate(req, res, next)
|
||||||
|
|
||||||
res.locals.authenticated = false
|
res.locals.authenticated = false
|
||||||
|
@ -82,7 +89,7 @@ function optionalAuthenticate (req: express.Request, res: express.Response, next
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function authenticateRunnerSocket (socket: Socket, next: (err?: any) => void) {
|
export function authenticateRunnerSocket (socket: Socket, next: (err?: any) => void) {
|
||||||
const runnerToken = socket.handshake.auth['runnerToken']
|
const runnerToken = socket.handshake.auth['runnerToken']
|
||||||
|
|
||||||
logger.debug('Checking runner token in socket.')
|
logger.debug('Checking runner token in socket.')
|
||||||
|
@ -100,13 +107,3 @@ function authenticateRunnerSocket (socket: Socket, next: (err?: any) => void) {
|
||||||
})
|
})
|
||||||
.catch(err => logger.error('Cannot get runner token.', { err }))
|
.catch(err => logger.error('Cannot get runner token.', { err }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export {
|
|
||||||
authenticate,
|
|
||||||
authenticateSocket,
|
|
||||||
authenticatePromise,
|
|
||||||
optionalAuthenticate,
|
|
||||||
authenticateRunnerSocket
|
|
||||||
}
|
|
||||||
|
|
|
@ -37,6 +37,8 @@ export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COL
|
||||||
|
|
||||||
export const userRegistrationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_REGISTRATIONS)
|
export const userRegistrationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_REGISTRATIONS)
|
||||||
|
|
||||||
|
export const tokenSessionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.TOKEN_SESSIONS)
|
||||||
|
|
||||||
export const runnersSortValidator = checkSortFactory(SORTABLE_COLUMNS.RUNNERS)
|
export const runnersSortValidator = checkSortFactory(SORTABLE_COLUMNS.RUNNERS)
|
||||||
export const runnerRegistrationTokensSortValidator = checkSortFactory(SORTABLE_COLUMNS.RUNNER_REGISTRATION_TOKENS)
|
export const runnerRegistrationTokensSortValidator = checkSortFactory(SORTABLE_COLUMNS.RUNNER_REGISTRATION_TOKENS)
|
||||||
export const runnerJobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.RUNNER_JOBS)
|
export const runnerJobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.RUNNER_JOBS)
|
||||||
|
|
46
server/core/middlewares/validators/token.ts
Normal file
46
server/core/middlewares/validators/token.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
|
||||||
|
import { isIdValid } from '@server/helpers/custom-validators/misc.js'
|
||||||
|
import { OAuthTokenModel } from '@server/models/oauth/oauth-token.js'
|
||||||
|
import express from 'express'
|
||||||
|
import { param } from 'express-validator'
|
||||||
|
import { checkUserCanManageAccount, checkUserIdExist } from './shared/users.js'
|
||||||
|
import { areValidationErrors } from './shared/utils.js'
|
||||||
|
|
||||||
|
export const manageTokenSessionsValidator = [
|
||||||
|
param('userId').custom(isIdValid),
|
||||||
|
|
||||||
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
if (areValidationErrors(req, res)) return
|
||||||
|
|
||||||
|
if (!await checkUserIdExist(req.params.userId, res)) return
|
||||||
|
|
||||||
|
const authUser = res.locals.oauth.token.User
|
||||||
|
const targetUser = res.locals.user
|
||||||
|
|
||||||
|
if (!checkUserCanManageAccount({ account: targetUser.Account, user: authUser, res, specialRight: UserRight.MANAGE_USERS })) return
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const revokeTokenSessionValidator = [
|
||||||
|
param('tokenSessionId').custom(isIdValid),
|
||||||
|
|
||||||
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
if (areValidationErrors(req, res)) return
|
||||||
|
|
||||||
|
const targetUser = res.locals.user
|
||||||
|
const session = await OAuthTokenModel.loadSessionOf({ id: +req.params.tokenSessionId, userId: targetUser.id })
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return res.fail({
|
||||||
|
status: HttpStatusCode.NOT_FOUND_404,
|
||||||
|
message: req.t('The token session does not exist or does not belong to the user.')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.locals.tokenSession = session
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
]
|
|
@ -1,4 +1,8 @@
|
||||||
import { Transaction } from 'sequelize'
|
import { TokenSession } from '@peertube/peertube-models'
|
||||||
|
import { TokensCache } from '@server/lib/auth/tokens-cache.js'
|
||||||
|
import { MUserAccountId } from '@server/types/models/index.js'
|
||||||
|
import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token.js'
|
||||||
|
import { Op, Transaction } from 'sequelize'
|
||||||
import {
|
import {
|
||||||
AfterDestroy,
|
AfterDestroy,
|
||||||
AfterUpdate,
|
AfterUpdate,
|
||||||
|
@ -11,15 +15,12 @@ import {
|
||||||
Table,
|
Table,
|
||||||
UpdatedAt
|
UpdatedAt
|
||||||
} from 'sequelize-typescript'
|
} from 'sequelize-typescript'
|
||||||
import { TokensCache } from '@server/lib/auth/tokens-cache.js'
|
|
||||||
import { MUserAccountId } from '@server/types/models/index.js'
|
|
||||||
import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token.js'
|
|
||||||
import { logger } from '../../helpers/logger.js'
|
import { logger } from '../../helpers/logger.js'
|
||||||
import { AccountModel } from '../account/account.js'
|
import { AccountModel } from '../account/account.js'
|
||||||
import { ActorModel } from '../actor/actor.js'
|
import { ActorModel } from '../actor/actor.js'
|
||||||
|
import { getSort, SequelizeModel } from '../shared/index.js'
|
||||||
import { UserModel } from '../user/user.js'
|
import { UserModel } from '../user/user.js'
|
||||||
import { OAuthClientModel } from './oauth-client.js'
|
import { OAuthClientModel } from './oauth-client.js'
|
||||||
import { SequelizeModel } from '../shared/index.js'
|
|
||||||
|
|
||||||
export type OAuthTokenInfo = {
|
export type OAuthTokenInfo = {
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
|
@ -99,6 +100,24 @@ export class OAuthTokenModel extends SequelizeModel<OAuthTokenModel> {
|
||||||
@Column
|
@Column
|
||||||
declare authName: string
|
declare authName: string
|
||||||
|
|
||||||
|
@Column
|
||||||
|
declare loginDevice: string
|
||||||
|
|
||||||
|
@Column
|
||||||
|
declare loginIP: string
|
||||||
|
|
||||||
|
@Column
|
||||||
|
declare loginDate: Date
|
||||||
|
|
||||||
|
@Column
|
||||||
|
declare lastActivityDevice: string
|
||||||
|
|
||||||
|
@Column
|
||||||
|
declare lastActivityIP: string
|
||||||
|
|
||||||
|
@Column
|
||||||
|
declare lastActivityDate: Date
|
||||||
|
|
||||||
@CreatedAt
|
@CreatedAt
|
||||||
declare createdAt: Date
|
declare createdAt: Date
|
||||||
|
|
||||||
|
@ -143,6 +162,8 @@ export class OAuthTokenModel extends SequelizeModel<OAuthTokenModel> {
|
||||||
return OAuthTokenModel.findOne(query)
|
return OAuthTokenModel.findOne(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static getByRefreshTokenAndPopulateClient (refreshToken: string) {
|
static getByRefreshTokenAndPopulateClient (refreshToken: string) {
|
||||||
const query = {
|
const query = {
|
||||||
where: {
|
where: {
|
||||||
|
@ -205,6 +226,59 @@ export class OAuthTokenModel extends SequelizeModel<OAuthTokenModel> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static loadSessionOf (options: {
|
||||||
|
id: number
|
||||||
|
userId: number
|
||||||
|
}) {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
return OAuthTokenModel.findOne({
|
||||||
|
where: {
|
||||||
|
id: options.id,
|
||||||
|
userId: options.userId,
|
||||||
|
accessTokenExpiresAt: {
|
||||||
|
[Op.gt]: now
|
||||||
|
},
|
||||||
|
refreshTokenExpiresAt: {
|
||||||
|
[Op.gt]: now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static async listSessionsOf (options: {
|
||||||
|
start: number
|
||||||
|
count: number
|
||||||
|
sort: string
|
||||||
|
userId: number
|
||||||
|
}) {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const { count, rows } = await OAuthTokenModel.findAndCountAll({
|
||||||
|
offset: options.start,
|
||||||
|
limit: options.count,
|
||||||
|
order: getSort(options.sort),
|
||||||
|
where: {
|
||||||
|
userId: options.userId,
|
||||||
|
accessTokenExpiresAt: {
|
||||||
|
[Op.gt]: now
|
||||||
|
},
|
||||||
|
refreshTokenExpiresAt: {
|
||||||
|
[Op.gt]: now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: count,
|
||||||
|
data: rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static deleteUserToken (userId: number, t?: Transaction) {
|
static deleteUserToken (userId: number, t?: Transaction) {
|
||||||
TokensCache.Instance.deleteUserToken(userId)
|
TokensCache.Instance.deleteUserToken(userId)
|
||||||
|
|
||||||
|
@ -217,4 +291,22 @@ export class OAuthTokenModel extends SequelizeModel<OAuthTokenModel> {
|
||||||
|
|
||||||
return OAuthTokenModel.destroy(query)
|
return OAuthTokenModel.destroy(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toSessionFormattedJSON (activeToken: string): TokenSession {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
|
||||||
|
loginIP: this.loginIP,
|
||||||
|
loginDevice: this.loginDevice,
|
||||||
|
loginDate: this.loginDate,
|
||||||
|
|
||||||
|
lastActivityIP: this.lastActivityIP,
|
||||||
|
lastActivityDevice: this.lastActivityDevice,
|
||||||
|
lastActivityDate: this.lastActivityDate,
|
||||||
|
|
||||||
|
currentSession: this.accessToken === activeToken,
|
||||||
|
|
||||||
|
createdAt: this.createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
4
server/core/types/express.d.ts
vendored
4
server/core/types/express.d.ts
vendored
|
@ -31,7 +31,7 @@ import {
|
||||||
MVideoThumbnailBlacklist,
|
MVideoThumbnailBlacklist,
|
||||||
MWatchedWordsList
|
MWatchedWordsList
|
||||||
} from '@server/types/models/index.js'
|
} from '@server/types/models/index.js'
|
||||||
import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token.js'
|
import { MOAuthToken, MOAuthTokenUser } from '@server/types/models/oauth/oauth-token.js'
|
||||||
import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server.js'
|
import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server.js'
|
||||||
import { MVideoImportDefault } from '@server/types/models/video/video-import.js'
|
import { MVideoImportDefault } from '@server/types/models/video/video-import.js'
|
||||||
import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element.js'
|
import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element.js'
|
||||||
|
@ -246,6 +246,8 @@ declare module 'express' {
|
||||||
userExport?: MUserExport
|
userExport?: MUserExport
|
||||||
|
|
||||||
watchedWordsList?: MWatchedWordsList
|
watchedWordsList?: MWatchedWordsList
|
||||||
|
|
||||||
|
tokenSession?: MOAuthToken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -152,6 +152,7 @@ import { OpenTelemetryMetrics } from '@server/lib/opentelemetry/metrics.js'
|
||||||
import { ApplicationModel } from '@server/models/application/application.js'
|
import { ApplicationModel } from '@server/models/application/application.js'
|
||||||
import { VideoChannelSyncLatestScheduler } from '@server/lib/schedulers/video-channel-sync-latest-scheduler.js'
|
import { VideoChannelSyncLatestScheduler } from '@server/lib/schedulers/video-channel-sync-latest-scheduler.js'
|
||||||
import { RemoveExpiredUserExportsScheduler } from '@server/lib/schedulers/remove-expired-user-exports-scheduler.js'
|
import { RemoveExpiredUserExportsScheduler } from '@server/lib/schedulers/remove-expired-user-exports-scheduler.js'
|
||||||
|
import { UpdateTokenSessionScheduler } from '@server/lib/schedulers/update-token-session-scheduler.js'
|
||||||
|
|
||||||
// ----------- Command line -----------
|
// ----------- Command line -----------
|
||||||
|
|
||||||
|
@ -325,6 +326,7 @@ async function startApplication () {
|
||||||
GeoIPUpdateScheduler.Instance.enable()
|
GeoIPUpdateScheduler.Instance.enable()
|
||||||
RunnerJobWatchDogScheduler.Instance.enable()
|
RunnerJobWatchDogScheduler.Instance.enable()
|
||||||
RemoveExpiredUserExportsScheduler.Instance.enable()
|
RemoveExpiredUserExportsScheduler.Instance.enable()
|
||||||
|
UpdateTokenSessionScheduler.Instance.enable()
|
||||||
|
|
||||||
OpenTelemetryMetrics.Instance.registerMetrics({ trackerServer })
|
OpenTelemetryMetrics.Instance.registerMetrics({ trackerServer })
|
||||||
|
|
||||||
|
|
|
@ -1643,6 +1643,44 @@ paths:
|
||||||
'200':
|
'200':
|
||||||
description: successful operation
|
description: successful operation
|
||||||
|
|
||||||
|
/api/v1/users/{id}/token-sessions:
|
||||||
|
get:
|
||||||
|
summary: List token sessions
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/id'
|
||||||
|
tags:
|
||||||
|
- Session
|
||||||
|
security:
|
||||||
|
- OAuth2: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: successful operation
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/TokenSession'
|
||||||
|
|
||||||
|
/api/v1/users/{id}/token-sessions/{tokenSessionId}/revoke:
|
||||||
|
get:
|
||||||
|
summary: List token sessions
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/tokenSessionId'
|
||||||
|
tags:
|
||||||
|
- Session
|
||||||
|
security:
|
||||||
|
- OAuth2: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: successful operation
|
||||||
|
|
||||||
/api/v1/users/ask-send-verify-email:
|
/api/v1/users/ask-send-verify-email:
|
||||||
post:
|
post:
|
||||||
summary: Resend user verification link
|
summary: Resend user verification link
|
||||||
|
@ -7586,6 +7624,13 @@ components:
|
||||||
description: Entity id
|
description: Entity id
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/id'
|
$ref: '#/components/schemas/id'
|
||||||
|
tokenSessionId:
|
||||||
|
name: tokenSessionId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Token session Id
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/id'
|
||||||
userId:
|
userId:
|
||||||
name: userId
|
name: userId
|
||||||
in: path
|
in: path
|
||||||
|
@ -11502,6 +11547,36 @@ components:
|
||||||
description: User can select live latency mode if enabled by the instance
|
description: User can select live latency mode if enabled by the instance
|
||||||
$ref: '#/components/schemas/LiveVideoLatencyMode'
|
$ref: '#/components/schemas/LiveVideoLatencyMode'
|
||||||
|
|
||||||
|
TokenSession:
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
currentSession:
|
||||||
|
type: boolean
|
||||||
|
description: Is this session the current one?
|
||||||
|
loginDevice:
|
||||||
|
type: string
|
||||||
|
description: Device used to login
|
||||||
|
loginIP:
|
||||||
|
type: string
|
||||||
|
format: ipv4
|
||||||
|
description: IP address used to login
|
||||||
|
loginDate:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Date of the login
|
||||||
|
lastActivityDevice:
|
||||||
|
type: string
|
||||||
|
lastActivityIP:
|
||||||
|
type: string
|
||||||
|
format: ipv4
|
||||||
|
lastActivityDate:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
RequestTwoFactorResponse:
|
RequestTwoFactorResponse:
|
||||||
properties:
|
properties:
|
||||||
otpRequest:
|
otpRequest:
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue