mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2025-10-03 09:49:20 +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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
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 {
|
||||
USER_CHANNEL_NAME_VALIDATOR,
|
||||
|
@ -14,15 +13,16 @@ import {
|
|||
USER_VIDEO_QUOTA_DAILY_VALIDATOR,
|
||||
USER_VIDEO_QUOTA_VALIDATOR
|
||||
} 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 { 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 { UserCreate, UserRole } from '@peertube/peertube-models'
|
||||
import { ActorAvatarEditComponent } from '../../../../shared/shared-actor-image-edit/actor-avatar-edit.component'
|
||||
import { InputTextComponent } from '../../../../shared/shared-forms/input-text.component'
|
||||
import { PeertubeCheckboxComponent } from '../../../../shared/shared-forms/peertube-checkbox.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 { UserRealQuotaInfoComponent } from '../../../shared/user-real-quota-info.component'
|
||||
import { UserEdit } from './user-edit'
|
||||
|
@ -34,20 +34,18 @@ import { UserPasswordComponent } from './user-password.component'
|
|||
styleUrls: [ './user-edit.component.scss' ],
|
||||
imports: [
|
||||
RouterLink,
|
||||
NgIf,
|
||||
CommonModule,
|
||||
NgTemplateOutlet,
|
||||
ActorAvatarEditComponent,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgClass,
|
||||
HelpComponent,
|
||||
InputTextComponent,
|
||||
NgFor,
|
||||
SelectCustomValueComponent,
|
||||
UserRealQuotaInfoComponent,
|
||||
PeertubeCheckboxComponent,
|
||||
UserPasswordComponent,
|
||||
BytesPipe,
|
||||
AccountTokenSessionsComponent,
|
||||
AlertComponent
|
||||
]
|
||||
})
|
||||
|
|
|
@ -123,11 +123,11 @@
|
|||
<div class="form-group" *ngIf="isCreation()">
|
||||
<label i18n for="password">Password</label>
|
||||
|
||||
<my-help *ngIf="isPasswordOptional()">
|
||||
<ng-container i18n>
|
||||
@if (isPasswordOptional()) {
|
||||
<div class="form-group-description" i18n>
|
||||
If you leave the password empty, an email will be sent to the user.
|
||||
</ng-container>
|
||||
</my-help>
|
||||
</div>
|
||||
}
|
||||
|
||||
<my-input-text formControlName="password" inputId="password" [formError]="formErrors['password']" autocomplete="new-password"></my-input-text>
|
||||
</div>
|
||||
|
@ -215,16 +215,27 @@
|
|||
</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="title-col">
|
||||
<div class="anchor" id="danger"></div> <!-- danger zone anchor -->
|
||||
<h2 i18n class="pt-title-danger">DANGER ZONE</h2>
|
||||
<div class="content-col">
|
||||
<my-account-token-sessions [user]="user"></my-account-token-sessions>
|
||||
</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="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>
|
||||
|
@ -240,6 +251,5 @@
|
|||
<button class="peertube-button danger-button" (click)="disableTwoFactorAuth()" i18n>Disable two factor authentication</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
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 { 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 { peertubeTranslate, USER_ROLE_LABELS } from '@peertube/peertube-core-utils'
|
||||
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.user) return false
|
||||
if (this.user.pluginAuth) return false
|
||||
|
@ -72,6 +72,13 @@ export abstract class UserEdit extends FormReactive implements OnInit {
|
|||
return true
|
||||
}
|
||||
|
||||
displayTokenSessions () {
|
||||
if (this.isCreation()) return false
|
||||
if (!this.user) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
resetPassword () {
|
||||
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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
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 {
|
||||
USER_EMAIL_VALIDATOR,
|
||||
|
@ -10,6 +9,7 @@ import {
|
|||
USER_VIDEO_QUOTA_DAILY_VALIDATOR,
|
||||
USER_VIDEO_QUOTA_VALIDATOR
|
||||
} 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 { AlertComponent } from '@app/shared/shared-main/common/alert.component'
|
||||
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 { PeertubeCheckboxComponent } from '../../../../shared/shared-forms/peertube-checkbox.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 { UserRealQuotaInfoComponent } from '../../../shared/user-real-quota-info.component'
|
||||
import { UserEdit } from './user-edit'
|
||||
import { UserPasswordComponent } from './user-password.component'
|
||||
import { AccountTokenSessionsComponent } from '@app/shared/shared-users/account-token-sessions.component'
|
||||
|
||||
@Component({
|
||||
selector: 'my-user-update',
|
||||
|
@ -32,19 +32,17 @@ import { UserPasswordComponent } from './user-password.component'
|
|||
styleUrls: [ './user-edit.component.scss' ],
|
||||
imports: [
|
||||
RouterLink,
|
||||
NgIf,
|
||||
NgTemplateOutlet,
|
||||
ActorAvatarEditComponent,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgClass,
|
||||
HelpComponent,
|
||||
CommonModule,
|
||||
InputTextComponent,
|
||||
NgFor,
|
||||
SelectCustomValueComponent,
|
||||
UserRealQuotaInfoComponent,
|
||||
PeertubeCheckboxComponent,
|
||||
UserPasswordComponent,
|
||||
AccountTokenSessionsComponent,
|
||||
BytesPipe,
|
||||
AlertComponent
|
||||
]
|
||||
|
|
|
@ -72,6 +72,18 @@
|
|||
</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="title-col">
|
||||
<h2 i18n>EMAIL</h2>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
@use "_variables" as *;
|
||||
@use "_mixins" as *;
|
||||
|
||||
.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 { AfterViewChecked, Component, OnInit, inject } from '@angular/core'
|
||||
import { AuthService, Notifier, User, UserService } from '@app/core'
|
||||
import { genericUploadErrorHandler } from '@app/helpers'
|
||||
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 { 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 { MyAccountDangerZoneComponent } from './my-account-danger-zone/my-account-danger-zone.component'
|
||||
import { MyAccountEmailPreferencesComponent } from './my-account-email-preferences/my-account-email-preferences.component'
|
||||
import {
|
||||
MyAccountNotificationPreferencesComponent
|
||||
} 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 { UserQuotaComponent } from '../../shared/shared-main/users/user-quota.component'
|
||||
import { ActorAvatarEditComponent } from '../../shared/shared-actor-image-edit/actor-avatar-edit.component'
|
||||
import { MyAccountTwoFactorButtonComponent } from './my-account-two-factor/my-account-two-factor-button.component'
|
||||
|
||||
@Component({
|
||||
selector: 'my-account-settings',
|
||||
templateUrl: './my-account-settings.component.html',
|
||||
styleUrls: [ './my-account-settings.component.scss' ],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ActorAvatarEditComponent,
|
||||
UserQuotaComponent,
|
||||
MyAccountProfileComponent,
|
||||
UserInterfaceSettingsComponent,
|
||||
UserVideoSettingsComponent,
|
||||
MyAccountNotificationPreferencesComponent,
|
||||
NgIf,
|
||||
MyAccountChangePasswordComponent,
|
||||
MyAccountTwoFactorButtonComponent,
|
||||
MyAccountEmailPreferencesComponent,
|
||||
MyAccountChangeEmailComponent,
|
||||
MyAccountDangerZoneComponent
|
||||
MyAccountDangerZoneComponent,
|
||||
AccountTokenSessionsComponent
|
||||
]
|
||||
})
|
||||
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 './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 { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
|
||||
|
||||
|
@ -6,10 +7,12 @@ type LoginOptions = OverrideCommandOptions & {
|
|||
client?: { id?: string, secret?: string }
|
||||
user?: { username: string, password?: string }
|
||||
otpToken?: string
|
||||
|
||||
userAgent?: string
|
||||
xForwardedFor?: string
|
||||
}
|
||||
|
||||
export class LoginCommand extends AbstractCommand {
|
||||
|
||||
async login (options: LoginOptions = {}) {
|
||||
const res = await this._login(options)
|
||||
|
||||
|
@ -43,10 +46,12 @@ export class LoginCommand extends AbstractCommand {
|
|||
}
|
||||
}
|
||||
|
||||
loginUsingExternalToken (options: OverrideCommandOptions & {
|
||||
username: string
|
||||
externalAuthToken: string
|
||||
}) {
|
||||
loginUsingExternalToken (
|
||||
options: OverrideCommandOptions & {
|
||||
username: string
|
||||
externalAuthToken: string
|
||||
}
|
||||
) {
|
||||
const { username, externalAuthToken } = options
|
||||
const path = '/api/v1/users/token'
|
||||
|
||||
|
@ -71,9 +76,11 @@ export class LoginCommand extends AbstractCommand {
|
|||
})
|
||||
}
|
||||
|
||||
logout (options: OverrideCommandOptions & {
|
||||
token: string
|
||||
}) {
|
||||
logout (
|
||||
options: OverrideCommandOptions & {
|
||||
token: string
|
||||
}
|
||||
) {
|
||||
const path = '/api/v1/users/revoke-token'
|
||||
|
||||
return unwrapBody<{ redirectUrl: string }>(this.postBodyRequest({
|
||||
|
@ -86,9 +93,13 @@ export class LoginCommand extends AbstractCommand {
|
|||
}))
|
||||
}
|
||||
|
||||
refreshToken (options: OverrideCommandOptions & {
|
||||
refreshToken: string
|
||||
}) {
|
||||
refreshToken (
|
||||
options: OverrideCommandOptions & {
|
||||
refreshToken: string
|
||||
userAgent?: string
|
||||
xForwardedFor?: string
|
||||
}
|
||||
) {
|
||||
const path = '/api/v1/users/token'
|
||||
|
||||
const body = {
|
||||
|
@ -99,11 +110,17 @@ export class LoginCommand extends AbstractCommand {
|
|||
grant_type: 'refresh_token'
|
||||
}
|
||||
|
||||
const headers = options.userAgent
|
||||
? { 'user-agent': options.userAgent }
|
||||
: {}
|
||||
|
||||
return this.postBodyRequest({
|
||||
...options,
|
||||
|
||||
path,
|
||||
requestType: 'form',
|
||||
headers,
|
||||
xForwardedFor: options.xForwardedFor,
|
||||
fields: body,
|
||||
implicitToken: false,
|
||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||
|
@ -137,9 +154,9 @@ export class LoginCommand extends AbstractCommand {
|
|||
scope: 'upload'
|
||||
}
|
||||
|
||||
const headers = otpToken
|
||||
? { 'x-peertube-otp': otpToken }
|
||||
: {}
|
||||
const headers: Record<string, string> = {}
|
||||
if (otpToken) headers['x-peertube-otp'] = otpToken
|
||||
if (options.userAgent) headers['user-agent'] = options.userAgent
|
||||
|
||||
return this.postBodyRequest({
|
||||
...options,
|
||||
|
@ -147,6 +164,7 @@ export class LoginCommand extends AbstractCommand {
|
|||
path,
|
||||
headers,
|
||||
requestType: 'form',
|
||||
xForwardedFor: options.xForwardedFor,
|
||||
fields: body,
|
||||
implicitToken: false,
|
||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||
|
@ -156,4 +174,43 @@ export class LoginCommand extends AbstractCommand {
|
|||
private unwrapLoginBody (body: any) {
|
||||
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 headers = options.userAgent
|
||||
? { 'user-agent': options.userAgent }
|
||||
: {}
|
||||
|
||||
return this.getRequestBody<MyUser>({
|
||||
...options,
|
||||
|
||||
path,
|
||||
headers,
|
||||
xForwardedFor: options.xForwardedFor,
|
||||
implicitToken: true,
|
||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||
})
|
||||
|
|
|
@ -22,6 +22,7 @@ import './runners.js'
|
|||
import './search.js'
|
||||
import './services.js'
|
||||
import './static.js'
|
||||
import './token-session.js'
|
||||
import './transcoding.js'
|
||||
import './two-factor.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 })
|
||||
})
|
||||
|
||||
it('Should success with the correct parameters', async function () {
|
||||
it('Should succeed with the correct parameters', async function () {
|
||||
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({
|
||||
url: server.url,
|
||||
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({
|
||||
url: server.url,
|
||||
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,
|
||||
path,
|
||||
fields: {
|
||||
ids: [ ]
|
||||
ids: []
|
||||
},
|
||||
token: server.accessToken,
|
||||
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||
|
@ -235,7 +235,6 @@ describe('Test user notifications API validators', function () {
|
|||
})
|
||||
|
||||
describe('When connecting to my notification socket', function () {
|
||||
|
||||
it('Should fail with no token', function (next) {
|
||||
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`, {
|
||||
query: { accessToken: server.accessToken },
|
||||
reconnection: false
|
||||
|
|
|
@ -50,7 +50,7 @@ describe('Test users API validators', function () {
|
|||
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' }
|
||||
|
||||
await makePostBodyRequest({
|
||||
|
|
|
@ -35,7 +35,7 @@ describe('Test video captions API validator', function () {
|
|||
})
|
||||
|
||||
describe('When adding video caption', function () {
|
||||
const fields = { }
|
||||
const fields = {}
|
||||
const attaches = {
|
||||
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'
|
||||
await makeUploadRequest({
|
||||
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({
|
||||
|
@ -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'
|
||||
await makeDeleteRequest({
|
||||
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({
|
||||
url: server.url,
|
||||
token: server.accessToken,
|
||||
|
@ -171,7 +171,6 @@ describe('Test video comments API validator', function () {
|
|||
})
|
||||
|
||||
describe('When adding a video thread', function () {
|
||||
|
||||
it('Should fail with a non authenticated user', async function () {
|
||||
const fields = {
|
||||
text: 'text'
|
||||
|
@ -243,7 +242,6 @@ describe('Test video comments API validator', function () {
|
|||
})
|
||||
|
||||
describe('When adding a comment to a thread', function () {
|
||||
|
||||
it('Should fail with a non authenticated user', async function () {
|
||||
const fields = {
|
||||
text: 'text'
|
||||
|
@ -399,7 +397,6 @@ describe('Test video comments API validator', function () {
|
|||
})
|
||||
|
||||
describe('When a video has comments disabled', function () {
|
||||
|
||||
before(async function () {
|
||||
video = await server.videos.upload({ attributes: { commentsPolicy: VideoCommentPolicy.DISABLED } })
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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: accountPath, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken })
|
||||
await makeGetRequest({
|
||||
|
@ -169,7 +169,7 @@ describe('Test video playlists API validator', function () {
|
|||
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 })
|
||||
})
|
||||
})
|
||||
|
|
|
@ -76,7 +76,7 @@ describe('Test videos API validator', function () {
|
|||
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 } })
|
||||
})
|
||||
})
|
||||
|
@ -96,7 +96,7 @@ describe('Test videos API validator', function () {
|
|||
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 })
|
||||
})
|
||||
})
|
||||
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
@ -189,7 +189,7 @@ describe('Test videos API validator', function () {
|
|||
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 })
|
||||
})
|
||||
})
|
||||
|
@ -213,7 +213,7 @@ describe('Test videos API validator', function () {
|
|||
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 })
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||
|
||||
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 {
|
||||
cleanupTests,
|
||||
createSingleServer,
|
||||
|
@ -38,7 +38,6 @@ describe('Test oauth', function () {
|
|||
})
|
||||
|
||||
describe('OAuth client', function () {
|
||||
|
||||
function expectInvalidClient (body: PeerTubeProblemDocument) {
|
||||
expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
|
||||
expect(body.detail).to.contain('client is invalid')
|
||||
|
@ -68,7 +67,6 @@ describe('Test oauth', function () {
|
|||
})
|
||||
|
||||
describe('Login', function () {
|
||||
|
||||
function expectInvalidCredentials (body: PeerTubeProblemDocument) {
|
||||
expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
|
||||
expect(body.detail).to.contain('credentials are invalid')
|
||||
|
@ -126,7 +124,6 @@ describe('Test oauth', function () {
|
|||
})
|
||||
|
||||
describe('Logout', function () {
|
||||
|
||||
it('Should logout (revoke token)', async function () {
|
||||
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 () {
|
||||
before(async function () {
|
||||
this.timeout(120_000)
|
||||
|
@ -219,7 +432,6 @@ describe('Test oauth', function () {
|
|||
await server.login.refreshToken({ refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
||||
})
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
await sqlCommand.cleanup()
|
||||
await cleanupTests([ server ])
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import express from 'express'
|
||||
import { ScopedToken } from '@peertube/peertube-models'
|
||||
import { ResultList, ScopedToken, TokenSession } from '@peertube/peertube-models'
|
||||
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||
import { logger } from '@server/helpers/logger.js'
|
||||
import { CONFIG } from '@server/initializers/config.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 { handleOAuthToken, MissingTwoFactorError } from '@server/lib/auth/oauth.js'
|
||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||
import { asyncMiddleware, authenticate, buildRateLimiter, openapiOperationDoc } from '@server/middlewares/index.js'
|
||||
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||
import {
|
||||
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()
|
||||
|
||||
|
@ -17,24 +28,51 @@ const loginRateLimiter = buildRateLimiter({
|
|||
max: CONFIG.RATES_LIMIT.LOGIN.MAX
|
||||
})
|
||||
|
||||
tokensRouter.post('/token',
|
||||
tokensRouter.post(
|
||||
'/token',
|
||||
loginRateLimiter,
|
||||
openapiOperationDoc({ operationId: 'getOAuthToken' }),
|
||||
asyncMiddleware(handleToken)
|
||||
)
|
||||
|
||||
tokensRouter.post('/revoke-token',
|
||||
tokensRouter.post(
|
||||
'/revoke-token',
|
||||
openapiOperationDoc({ operationId: 'revokeOAuthToken' }),
|
||||
authenticate,
|
||||
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,
|
||||
getScopedTokens
|
||||
)
|
||||
|
||||
tokensRouter.post('/scoped-tokens',
|
||||
tokensRouter.post(
|
||||
'/scoped-tokens',
|
||||
authenticate,
|
||||
asyncMiddleware(renewScopedTokens)
|
||||
)
|
||||
|
@ -101,6 +139,36 @@ async function handleTokenRevocation (req: express.Request, res: express.Respons
|
|||
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) {
|
||||
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' ],
|
||||
|
||||
TOKEN_SESSIONS: [ 'createdAt' ],
|
||||
|
||||
RUNNERS: [ 'createdAt' ],
|
||||
RUNNER_REGISTRATION_TOKENS: [ 'createdAt' ],
|
||||
RUNNER_JOBS: [ 'updatedAt', 'createdAt', 'priority', 'state', 'progress' ],
|
||||
|
@ -339,6 +341,7 @@ export const SCHEDULER_INTERVALS_MS = {
|
|||
ACTOR_FOLLOW_SCORES: 60000 * 60, // 1 hour
|
||||
REMOVE_OLD_JOBS: 60000 * 60, // 1 hour
|
||||
UPDATE_VIDEOS: 60000, // 1 minute
|
||||
UPDATE_TOKEN_SESSION: 60000, // 1 minute
|
||||
YOUTUBE_DL_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,
|
||||
|
@ -1208,6 +1211,7 @@ if (process.env.PRODUCTION_CONSTANTS !== 'true') {
|
|||
SCHEDULER_INTERVALS_MS.AUTO_FOLLOW_INDEX_INSTANCES = 5000
|
||||
SCHEDULER_INTERVALS_MS.UPDATE_INBOX_STATS = 5000
|
||||
SCHEDULER_INTERVALS_MS.CHECK_PEERTUBE_VERSION = 2000
|
||||
SCHEDULER_INTERVALS_MS.UPDATE_TOKEN_SESSION = 2000
|
||||
|
||||
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
|
||||
accessTokenExpiresAt: Date
|
||||
refreshTokenExpiresAt: Date
|
||||
loginDevice: string
|
||||
loginIP: string
|
||||
loginDate: Date
|
||||
lastActivityDevice: string
|
||||
lastActivityIP: string
|
||||
lastActivityDate: Date
|
||||
}
|
||||
|
||||
export type BypassLogin = {
|
||||
|
@ -194,13 +200,21 @@ async function saveToken (
|
|||
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 = {
|
||||
accessToken: token.accessToken,
|
||||
accessTokenExpiresAt: token.accessTokenExpiresAt,
|
||||
refreshToken: token.refreshToken,
|
||||
refreshTokenExpiresAt: token.refreshTokenExpiresAt,
|
||||
...pick(token, [
|
||||
'accessToken',
|
||||
'refreshToken',
|
||||
'accessTokenExpiresAt',
|
||||
'refreshTokenExpiresAt',
|
||||
'loginDevice',
|
||||
'loginIP',
|
||||
'loginDate',
|
||||
'lastActivityDate',
|
||||
'lastActivityDevice',
|
||||
'lastActivityIP'
|
||||
]),
|
||||
authName,
|
||||
oAuthClientId: client.id,
|
||||
userId: user.id
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import express from 'express'
|
||||
import OAuth2Server, {
|
||||
InvalidClientError,
|
||||
InvalidGrantError,
|
||||
|
@ -8,16 +7,18 @@ import OAuth2Server, {
|
|||
UnauthorizedClientError,
|
||||
UnsupportedGrantTypeError
|
||||
} 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 { isOTPValid } from '@server/helpers/otp.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { UserRegistrationModel } from '@server/models/user/user-registration.js'
|
||||
import { MOAuthClient } from '@server/types/models/index.js'
|
||||
import { sha1 } from '@peertube/peertube-node-utils'
|
||||
import { HttpStatusCode, ServerErrorCode, UserRegistrationState } from '@peertube/peertube-models'
|
||||
import express from 'express'
|
||||
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 { BypassLogin, getAccessToken, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model.js'
|
||||
|
||||
class MissingTwoFactorError extends Error {
|
||||
code = HttpStatusCode.UNAUTHORIZED_401
|
||||
|
@ -40,9 +41,7 @@ class RegistrationApprovalRejected extends Error {
|
|||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Reimplement some functions of OAuth2Server to inject external auth methods
|
||||
*
|
||||
*/
|
||||
const oAuthServer = new OAuth2Server({
|
||||
// Wants seconds
|
||||
|
@ -99,18 +98,25 @@ async function handleOAuthToken (req: express.Request, options: { refreshTokenAu
|
|||
throw new UnauthorizedClientError('Unauthorized client: `grant_type` is invalid')
|
||||
}
|
||||
|
||||
const ip = req.ip
|
||||
const userAgent = req.headers['user-agent']
|
||||
|
||||
if (grantType === 'password') {
|
||||
return handlePasswordGrant({
|
||||
request,
|
||||
client,
|
||||
bypassLogin
|
||||
bypassLogin,
|
||||
ip,
|
||||
userAgent
|
||||
})
|
||||
}
|
||||
|
||||
return handleRefreshGrant({
|
||||
request,
|
||||
client,
|
||||
refreshTokenAuthName
|
||||
refreshTokenAuthName,
|
||||
ip,
|
||||
userAgent
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -122,11 +128,10 @@ function handleOAuthAuthenticate (
|
|||
}
|
||||
|
||||
export {
|
||||
MissingTwoFactorError,
|
||||
InvalidTwoFactorError,
|
||||
|
||||
handleOAuthAuthenticate,
|
||||
handleOAuthToken,
|
||||
handleOAuthAuthenticate
|
||||
InvalidTwoFactorError,
|
||||
MissingTwoFactorError
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -135,6 +140,8 @@ async function handlePasswordGrant (options: {
|
|||
request: Request
|
||||
client: MOAuthClient
|
||||
bypassLogin?: BypassLogin
|
||||
ip: string
|
||||
userAgent: string
|
||||
}) {
|
||||
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 })
|
||||
}
|
||||
|
@ -186,6 +202,8 @@ async function handleRefreshGrant (options: {
|
|||
request: Request
|
||||
client: MOAuthClient
|
||||
refreshTokenAuthName: string
|
||||
ip: string
|
||||
userAgent: string
|
||||
}) {
|
||||
const { request, client, refreshTokenAuthName } = options
|
||||
|
||||
|
@ -209,7 +227,17 @@ async function handleRefreshGrant (options: {
|
|||
|
||||
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 })
|
||||
}
|
||||
|
@ -227,13 +255,29 @@ function getTokenExpiresAt (type: 'access' | 'refresh') {
|
|||
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() ])
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
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 { getAccessToken } from '@server/lib/auth/oauth-model.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 { 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)
|
||||
.then((token: any) => {
|
||||
res.locals.oauth = { token }
|
||||
res.locals.authenticated = true
|
||||
|
||||
token.lastActivityDate = new Date()
|
||||
token.lastActivityIP = req.ip
|
||||
token.lastActivityDevice = req.header('user-agent')
|
||||
|
||||
UpdateTokenSessionScheduler.Instance.addToUpdate(token)
|
||||
|
||||
return next()
|
||||
})
|
||||
.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']
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
function authenticatePromise (options: {
|
||||
export function authenticatePromise (options: {
|
||||
req: express.Request
|
||||
res: express.Response
|
||||
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)
|
||||
|
||||
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']
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 tokenSessionsSortValidator = checkSortFactory(SORTABLE_COLUMNS.TOKEN_SESSIONS)
|
||||
|
||||
export const runnersSortValidator = checkSortFactory(SORTABLE_COLUMNS.RUNNERS)
|
||||
export const runnerRegistrationTokensSortValidator = checkSortFactory(SORTABLE_COLUMNS.RUNNER_REGISTRATION_TOKENS)
|
||||
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 {
|
||||
AfterDestroy,
|
||||
AfterUpdate,
|
||||
|
@ -11,15 +15,12 @@ import {
|
|||
Table,
|
||||
UpdatedAt
|
||||
} 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 { AccountModel } from '../account/account.js'
|
||||
import { ActorModel } from '../actor/actor.js'
|
||||
import { getSort, SequelizeModel } from '../shared/index.js'
|
||||
import { UserModel } from '../user/user.js'
|
||||
import { OAuthClientModel } from './oauth-client.js'
|
||||
import { SequelizeModel } from '../shared/index.js'
|
||||
|
||||
export type OAuthTokenInfo = {
|
||||
refreshToken: string
|
||||
|
@ -99,6 +100,24 @@ export class OAuthTokenModel extends SequelizeModel<OAuthTokenModel> {
|
|||
@Column
|
||||
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
|
||||
declare createdAt: Date
|
||||
|
||||
|
@ -143,6 +162,8 @@ export class OAuthTokenModel extends SequelizeModel<OAuthTokenModel> {
|
|||
return OAuthTokenModel.findOne(query)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static getByRefreshTokenAndPopulateClient (refreshToken: string) {
|
||||
const query = {
|
||||
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) {
|
||||
TokensCache.Instance.deleteUserToken(userId)
|
||||
|
||||
|
@ -217,4 +291,22 @@ export class OAuthTokenModel extends SequelizeModel<OAuthTokenModel> {
|
|||
|
||||
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,
|
||||
MWatchedWordsList
|
||||
} 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 { MVideoImportDefault } from '@server/types/models/video/video-import.js'
|
||||
import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element.js'
|
||||
|
@ -246,6 +246,8 @@ declare module 'express' {
|
|||
userExport?: MUserExport
|
||||
|
||||
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 { VideoChannelSyncLatestScheduler } from '@server/lib/schedulers/video-channel-sync-latest-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 -----------
|
||||
|
||||
|
@ -325,6 +326,7 @@ async function startApplication () {
|
|||
GeoIPUpdateScheduler.Instance.enable()
|
||||
RunnerJobWatchDogScheduler.Instance.enable()
|
||||
RemoveExpiredUserExportsScheduler.Instance.enable()
|
||||
UpdateTokenSessionScheduler.Instance.enable()
|
||||
|
||||
OpenTelemetryMetrics.Instance.registerMetrics({ trackerServer })
|
||||
|
||||
|
|
|
@ -1643,6 +1643,44 @@ paths:
|
|||
'200':
|
||||
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:
|
||||
post:
|
||||
summary: Resend user verification link
|
||||
|
@ -7586,6 +7624,13 @@ components:
|
|||
description: Entity id
|
||||
schema:
|
||||
$ref: '#/components/schemas/id'
|
||||
tokenSessionId:
|
||||
name: tokenSessionId
|
||||
in: path
|
||||
required: true
|
||||
description: Token session Id
|
||||
schema:
|
||||
$ref: '#/components/schemas/id'
|
||||
userId:
|
||||
name: userId
|
||||
in: path
|
||||
|
@ -11502,6 +11547,36 @@ components:
|
|||
description: User can select live latency mode if enabled by the instance
|
||||
$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:
|
||||
properties:
|
||||
otpRequest:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue