diff --git a/client/src/app/+admin/overview/users/user-edit/user-create.component.ts b/client/src/app/+admin/overview/users/user-edit/user-create.component.ts index 247ad2ea7..5a5f09733 100644 --- a/client/src/app/+admin/overview/users/user-edit/user-create.component.ts +++ b/client/src/app/+admin/overview/users/user-edit/user-create.component.ts @@ -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 ] }) diff --git a/client/src/app/+admin/overview/users/user-edit/user-edit.component.html b/client/src/app/+admin/overview/users/user-edit/user-edit.component.html index 9bde28108..2973ca0b1 100644 --- a/client/src/app/+admin/overview/users/user-edit/user-edit.component.html +++ b/client/src/app/+admin/overview/users/user-edit/user-edit.component.html @@ -123,11 +123,11 @@
- - + @if (isPasswordOptional()) { +
If you leave the password empty, an email will be sent to the user. - - +
+ }
@@ -215,16 +215,27 @@ +@if (displayTokenSessions()) { +
+
+
+

TOKEN SESSIONS

+
-
-
-
-

DANGER ZONE

+
+ +
+} -
+@if (displayPasswordZone()) { +
+
+
+

DANGER ZONE

+
-
+
Send a link to reset the password by email to the user
@@ -240,6 +251,5 @@
-
-
+} diff --git a/client/src/app/+admin/overview/users/user-edit/user-edit.ts b/client/src/app/+admin/overview/users/user-edit/user-edit.ts index 08cc413f6..532b1c70f 100644 --- a/client/src/app/+admin/overview/users/user-edit/user-edit.ts +++ b/client/src/app/+admin/overview/users/user-edit/user-edit.ts @@ -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 } diff --git a/client/src/app/+admin/overview/users/user-edit/user-update.component.ts b/client/src/app/+admin/overview/users/user-edit/user-update.component.ts index a34ed7334..35393cc6e 100644 --- a/client/src/app/+admin/overview/users/user-edit/user-update.component.ts +++ b/client/src/app/+admin/overview/users/user-edit/user-update.component.ts @@ -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 ] diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html index be3625966..b604663b6 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html +++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html @@ -72,6 +72,18 @@
+
+
+

ACCOUNT SESSIONS

+
+ +
+

These are the devices currently logged in to your account.

+ + +
+
+

EMAIL

diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss b/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss index 3ac3c1add..1b73ef09b 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss +++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss @@ -1,6 +1,6 @@ -@use '_variables' as *; -@use '_mixins' as *; +@use "_variables" as *; +@use "_mixins" as *; .content-col { - max-width: 500px;; + max-width: 800px; } diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts b/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts index fb02777fd..2d7c52573 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts +++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts @@ -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 { diff --git a/client/src/app/shared/shared-users/account-token-sessions.component.html b/client/src/app/shared/shared-users/account-token-sessions.component.html new file mode 100644 index 000000000..e16d9645d --- /dev/null +++ b/client/src/app/shared/shared-users/account-token-sessions.component.html @@ -0,0 +1,40 @@ +
+ + + + + + + + + + + + @if (sessions.length === 0) { + + + + } @else { + + + + + + + + + + } + +
DeviceIPLast ActiveActions
No active sessions
{{ session.browserName }} {{ session.browserVersion }} on {{ session.osName }} {{ session.osVersion }}{{ session.lastActivityIP }} + @if (session.currentSession) { + Current Session + } @else { + {{ session.lastActivityDate | date: 'short' }} + } + + @if (!session.currentSession) { + + } +
+
diff --git a/client/src/app/shared/shared-users/account-token-sessions.component.scss b/client/src/app/shared/shared-users/account-token-sessions.component.scss new file mode 100644 index 000000000..09afb7465 --- /dev/null +++ b/client/src/app/shared/shared-users/account-token-sessions.component.scss @@ -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); +} diff --git a/client/src/app/shared/shared-users/account-token-sessions.component.ts b/client/src/app/shared/shared-users/account-token-sessions.component.ts new file mode 100644 index 000000000..1c32215bc --- /dev/null +++ b/client/src/app/shared/shared-users/account-token-sessions.component.ts @@ -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() + + 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) + }) + } +} diff --git a/client/src/app/shared/shared-users/token-session.service.ts b/client/src/app/shared/shared-users/token-session.service.ts new file mode 100644 index 000000000..77a71b886 --- /dev/null +++ b/client/src/app/shared/shared-users/token-session.service.ts @@ -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>(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))) + } +} diff --git a/packages/models/src/tokens/index.ts b/packages/models/src/tokens/index.ts index db2d63d21..5782ca856 100644 --- a/packages/models/src/tokens/index.ts +++ b/packages/models/src/tokens/index.ts @@ -1 +1,2 @@ export * from './oauth-client-local.model.js' +export * from './token-session.model.js' diff --git a/packages/models/src/tokens/token-session.model.ts b/packages/models/src/tokens/token-session.model.ts new file mode 100644 index 000000000..eae066661 --- /dev/null +++ b/packages/models/src/tokens/token-session.model.ts @@ -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 +} diff --git a/packages/server-commands/src/users/login-command.ts b/packages/server-commands/src/users/login-command.ts index 92d123dfc..72eafad98 100644 --- a/packages/server-commands/src/users/login-command.ts +++ b/packages/server-commands/src/users/login-command.ts @@ -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 = {} + 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>({ + ...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 + }) + } } diff --git a/packages/server-commands/src/users/users-command.ts b/packages/server-commands/src/users/users-command.ts index 1a8b3a044..a690ed623 100644 --- a/packages/server-commands/src/users/users-command.ts +++ b/packages/server-commands/src/users/users-command.ts @@ -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({ ...options, path, + headers, + xForwardedFor: options.xForwardedFor, implicitToken: true, defaultExpectedStatus: HttpStatusCode.OK_200 }) diff --git a/packages/tests/src/api/check-params/index.ts b/packages/tests/src/api/check-params/index.ts index d9752eaab..5b9db62a9 100644 --- a/packages/tests/src/api/check-params/index.ts +++ b/packages/tests/src/api/check-params/index.ts @@ -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' diff --git a/packages/tests/src/api/check-params/my-user.ts b/packages/tests/src/api/check-params/my-user.ts index 3cbb0457d..3030dc7fe 100644 --- a/packages/tests/src/api/check-params/my-user.ts +++ b/packages/tests/src/api/check-params/my-user.ts @@ -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 }) }) }) diff --git a/packages/tests/src/api/check-params/plugins.ts b/packages/tests/src/api/check-params/plugins.ts index 7d1a28a62..dec60b2e2 100644 --- a/packages/tests/src/api/check-params/plugins.ts +++ b/packages/tests/src/api/check-params/plugins.ts @@ -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, diff --git a/packages/tests/src/api/check-params/token-session.ts b/packages/tests/src/api/check-params/token-session.ts new file mode 100644 index 000000000..e5644fd3e --- /dev/null +++ b/packages/tests/src/api/check-params/token-session.ts @@ -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 ]) + }) +}) diff --git a/packages/tests/src/api/check-params/user-notifications.ts b/packages/tests/src/api/check-params/user-notifications.ts index 8001eeff5..8f0782f64 100644 --- a/packages/tests/src/api/check-params/user-notifications.ts +++ b/packages/tests/src/api/check-params/user-notifications.ts @@ -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 diff --git a/packages/tests/src/api/check-params/users-emails.ts b/packages/tests/src/api/check-params/users-emails.ts index e382190ec..77abe766c 100644 --- a/packages/tests/src/api/check-params/users-emails.ts +++ b/packages/tests/src/api/check-params/users-emails.ts @@ -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({ diff --git a/packages/tests/src/api/check-params/video-captions.ts b/packages/tests/src/api/check-params/video-captions.ts index ac4e85068..18aeb0b7b 100644 --- a/packages/tests/src/api/check-params/video-captions.ts +++ b/packages/tests/src/api/check-params/video-captions.ts @@ -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, diff --git a/packages/tests/src/api/check-params/video-comments.ts b/packages/tests/src/api/check-params/video-comments.ts index e187f28e6..81da2b475 100644 --- a/packages/tests/src/api/check-params/video-comments.ts +++ b/packages/tests/src/api/check-params/video-comments.ts @@ -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` diff --git a/packages/tests/src/api/check-params/video-imports.ts b/packages/tests/src/api/check-params/video-imports.ts index 4866bdb75..0e33d85cb 100644 --- a/packages/tests/src/api/check-params/video-imports.ts +++ b/packages/tests/src/api/check-params/video-imports.ts @@ -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 }) }) }) diff --git a/packages/tests/src/api/check-params/video-playlists.ts b/packages/tests/src/api/check-params/video-playlists.ts index 533f8636e..46581bb59 100644 --- a/packages/tests/src/api/check-params/video-playlists.ts +++ b/packages/tests/src/api/check-params/video-playlists.ts @@ -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 }) }) }) diff --git a/packages/tests/src/api/check-params/videos.ts b/packages/tests/src/api/check-params/videos.ts index 61120c4e2..7e44c9d1d 100644 --- a/packages/tests/src/api/check-params/videos.ts +++ b/packages/tests/src/api/check-params/videos.ts @@ -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 }) }) }) diff --git a/packages/tests/src/api/users/oauth.ts b/packages/tests/src/api/users/oauth.ts index 4faf61b09..355af81a9 100644 --- a/packages/tests/src/api/users/oauth.ts +++ b/packages/tests/src/api/users/oauth.ts @@ -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 ]) diff --git a/server/core/controllers/api/users/token.ts b/server/core/controllers/api/users/token.ts index 19dce70d1..1952092a8 100644 --- a/server/core/controllers/api/users/token.ts +++ b/server/core/controllers/api/users/token.ts @@ -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 + ) +} + +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 diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index a8582d477..b951d5c3f 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -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 } diff --git a/server/core/initializers/migrations/0920-token-sessions.ts b/server/core/initializers/migrations/0920-token-sessions.ts new file mode 100644 index 000000000..8944dd4dc --- /dev/null +++ b/server/core/initializers/migrations/0920-token-sessions.ts @@ -0,0 +1,46 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise { + 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 +} diff --git a/server/core/lib/auth/oauth-model.ts b/server/core/lib/auth/oauth-model.ts index cbb2718ee..eef59599d 100644 --- a/server/core/lib/auth/oauth-model.ts +++ b/server/core/lib/auth/oauth-model.ts @@ -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 diff --git a/server/core/lib/auth/oauth.ts b/server/core/lib/auth/oauth.ts index ce05df0a4..3451c5e07 100644 --- a/server/core/lib/auth/oauth.ts +++ b/server/core/lib/auth/oauth.ts @@ -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' + ]) } } diff --git a/server/core/lib/schedulers/update-token-session-scheduler.ts b/server/core/lib/schedulers/update-token-session-scheduler.ts new file mode 100644 index 000000000..b256ac4cb --- /dev/null +++ b/server/core/lib/schedulers/update-token-session-scheduler.ts @@ -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() + + 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()) + } +} diff --git a/server/core/middlewares/auth.ts b/server/core/middlewares/auth.ts index 6eddd80bd..61fcc5049 100644 --- a/server/core/middlewares/auth.ts +++ b/server/core/middlewares/auth.ts @@ -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 -} diff --git a/server/core/middlewares/validators/sort.ts b/server/core/middlewares/validators/sort.ts index 777c7cdee..e601e9ab3 100644 --- a/server/core/middlewares/validators/sort.ts +++ b/server/core/middlewares/validators/sort.ts @@ -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) diff --git a/server/core/middlewares/validators/token.ts b/server/core/middlewares/validators/token.ts new file mode 100644 index 000000000..815684794 --- /dev/null +++ b/server/core/middlewares/validators/token.ts @@ -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() + } +] diff --git a/server/core/models/oauth/oauth-token.ts b/server/core/models/oauth/oauth-token.ts index 3743fba53..6abb46613 100644 --- a/server/core/models/oauth/oauth-token.ts +++ b/server/core/models/oauth/oauth-token.ts @@ -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 { @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 { return OAuthTokenModel.findOne(query) } + // --------------------------------------------------------------------------- + static getByRefreshTokenAndPopulateClient (refreshToken: string) { const query = { where: { @@ -205,6 +226,59 @@ export class OAuthTokenModel extends SequelizeModel { }) } + // --------------------------------------------------------------------------- + + 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 { 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 + } + } } diff --git a/server/core/types/express.d.ts b/server/core/types/express.d.ts index d649a48ca..bb81a59a2 100644 --- a/server/core/types/express.d.ts +++ b/server/core/types/express.d.ts @@ -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 } } } diff --git a/server/server.ts b/server/server.ts index ebf8c3391..64d8ef3fb 100644 --- a/server/server.ts +++ b/server/server.ts @@ -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 }) diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index b0b52652e..120abfaac 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -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: