1
0
Fork 0
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:
Chocobozzz 2025-07-30 11:33:07 +02:00
parent a53ed039b8
commit 57caf25611
No known key found for this signature in database
GPG key ID: 583A612D890159BE
40 changed files with 1158 additions and 138 deletions

View file

@ -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
] ]
}) })

View file

@ -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> }

View file

@ -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
} }

View file

@ -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
] ]

View file

@ -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>

View file

@ -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;
} }

View file

@ -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 {

View file

@ -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>

View file

@ -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);
}

View file

@ -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)
})
}
}

View 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)))
}
}

View file

@ -1 +1,2 @@
export * from './oauth-client-local.model.js' export * from './oauth-client-local.model.js'
export * from './token-session.model.js'

View 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
}

View file

@ -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
})
}
} }

View file

@ -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
}) })

View file

@ -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'

View file

@ -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 })
}) })
}) })

View file

@ -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,

View 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 ])
})
})

View file

@ -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

View file

@ -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({

View file

@ -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,

View file

@ -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`

View file

@ -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 })
}) })
}) })

View file

@ -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 })
}) })
}) })

View file

@ -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 })
}) })
}) })

View file

@ -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 ])

View file

@ -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

View file

@ -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 }

View 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
}

View file

@ -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

View file

@ -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'
])
} }
} }

View 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())
}
}

View file

@ -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
}

View file

@ -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)

View 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()
}
]

View file

@ -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
}
}
} }

View file

@ -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
} }
} }
} }

View file

@ -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 })

View file

@ -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: