1
0
Fork 0
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:
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 { 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
]
})

View file

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

View file

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

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

View file

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

View file

@ -1,6 +1,6 @@
@use '_variables' as *;
@use '_mixins' as *;
@use "_variables" as *;
@use "_mixins" as *;
.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 { 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 {

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 './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 { 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
})
}
}

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 headers = options.userAgent
? { 'user-agent': options.userAgent }
: {}
return this.getRequestBody<MyUser>({
...options,
path,
headers,
xForwardedFor: options.xForwardedFor,
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})

View file

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

View file

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

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

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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

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' ],
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 }

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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