From c437bc253416d990ef28508146b6b10bd85fc028 Mon Sep 17 00:00:00 2001 From: csehatt741 <77381875+csehatt741@users.noreply.github.com> Date: Sun, 29 Jun 2025 08:43:29 +0200 Subject: [PATCH] Feature/allow user to rotate Security Token (#5016) * Allow user to rotate Security Token * Update changelog --- CHANGELOG.md | 6 +- .../app/user/update-own-access-token.dto.ts | 6 ++ apps/api/src/app/user/user.controller.ts | 84 +++++++++++++------ apps/api/src/app/user/user.service.ts | 5 ++ .../admin-users/admin-users.component.ts | 2 +- .../user-account-access.component.ts | 56 ++++++++++++- .../user-account-access.html | 50 +++++++++++ .../user-account-access.module.ts | 11 ++- apps/client/src/app/services/data.service.ts | 22 +++-- libs/common/src/lib/permissions.ts | 3 +- 10 files changed, 204 insertions(+), 41 deletions(-) create mode 100644 apps/api/src/app/user/update-own-access-token.dto.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9080d7ebe..b1cfe4359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added support for generating a new _Security Token_ via the user’s account access panel + ### Changed - Renamed `Account` to `account` in the `Order` database schema @@ -6114,7 +6118,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Changed the navigation to always show the portfolio page - Migrated the data type of currencies from `enum` to `string` in the database - Supported unlimited currencies (instead of `CHF`, `EUR`, `GBP` and `USD`) -- Respected the accounts' currencies in the exchange rate service +- Respected the accounts’ currencies in the exchange rate service ### Fixed diff --git a/apps/api/src/app/user/update-own-access-token.dto.ts b/apps/api/src/app/user/update-own-access-token.dto.ts new file mode 100644 index 000000000..42f6f7289 --- /dev/null +++ b/apps/api/src/app/user/update-own-access-token.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class UpdateOwnAccessTokenDto { + @IsString() + accessToken: string; +} diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 868af505b..7190060ff 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -33,6 +33,7 @@ import { merge, size } from 'lodash'; import { DeleteOwnUserDto } from './delete-own-user.dto'; import { UserItem } from './interfaces/user-item.interface'; +import { UpdateOwnAccessTokenDto } from './update-own-access-token.dto'; import { UpdateUserSettingDto } from './update-user-setting.dto'; import { UserService } from './user.service'; @@ -53,24 +54,12 @@ export class UserController { public async deleteOwnUser( @Body() data: DeleteOwnUserDto ): Promise { - const hashedAccessToken = this.userService.createAccessToken({ - password: data.accessToken, - salt: this.configurationService.get('ACCESS_TOKEN_SALT') - }); - - const [user] = await this.userService.users({ - where: { accessToken: hashedAccessToken, id: this.request.user.id } - }); - - if (!user) { - throw new HttpException( - getReasonPhrase(StatusCodes.FORBIDDEN), - StatusCodes.FORBIDDEN - ); - } + const user = await this.validateAccessToken( + data.accessToken, + this.request.user.id + ); return this.userService.deleteUser({ - accessToken: hashedAccessToken, id: user.id }); } @@ -94,20 +83,24 @@ export class UserController { @HasPermission(permissions.accessAdminControl) @Post(':id/access-token') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public async generateAccessToken( + public async updateUserAccessToken( @Param('id') id: string ): Promise { - const { accessToken, hashedAccessToken } = - this.userService.generateAccessToken({ - userId: id - }); + return this.rotateUserAccessToken(id); + } - await this.prismaService.user.update({ - data: { accessToken: hashedAccessToken }, - where: { id } - }); + @HasPermission(permissions.updateOwnAccessToken) + @Post('access-token') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateOwnAccessToken( + @Body() data: UpdateOwnAccessTokenDto + ): Promise { + const user = await this.validateAccessToken( + data.accessToken, + this.request.user.id + ); - return { accessToken }; + return this.rotateUserAccessToken(user.id); } @Get() @@ -189,4 +182,43 @@ export class UserController { userId: this.request.user.id }); } + + private async rotateUserAccessToken( + userId: string + ): Promise { + const { accessToken, hashedAccessToken } = + this.userService.generateAccessToken({ + userId + }); + + await this.prismaService.user.update({ + data: { accessToken: hashedAccessToken }, + where: { id: userId } + }); + + return { accessToken }; + } + + private async validateAccessToken( + accessToken: string, + userId: string + ): Promise { + const hashedAccessToken = this.userService.createAccessToken({ + password: accessToken, + salt: this.configurationService.get('ACCESS_TOKEN_SALT') + }); + + const [user] = await this.userService.users({ + where: { accessToken: hashedAccessToken, id: userId } + }); + + if (!user) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return user; + } } diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index a229e36c4..0ca3fda33 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -354,6 +354,11 @@ export class UserService { let currentPermissions = getPermissions(user.role); + if (user.provider === 'ANONYMOUS') { + currentPermissions.push(permissions.deleteOwnUser); + currentPermissions.push(permissions.updateOwnAccessToken); + } + if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) { // currentPermissions = without( // currentPermissions, diff --git a/apps/client/src/app/components/admin-users/admin-users.component.ts b/apps/client/src/app/components/admin-users/admin-users.component.ts index e1cd3102c..6f1202476 100644 --- a/apps/client/src/app/components/admin-users/admin-users.component.ts +++ b/apps/client/src/app/components/admin-users/admin-users.component.ts @@ -147,7 +147,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit { this.notificationService.confirm({ confirmFn: () => { this.dataService - .generateAccessToken(aUserId) + .updateUserAccessToken(aUserId) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ accessToken }) => { this.notificationService.alert({ diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts index 6f111f456..285f7a603 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.component.ts +++ b/apps/client/src/app/components/user-account-access/user-account-access.component.ts @@ -1,5 +1,8 @@ import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; +import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type'; +import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { DataService } from '@ghostfolio/client/services/data.service'; +import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { Access, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @@ -11,11 +14,12 @@ import { OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, Validators } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { EMPTY, Subject } from 'rxjs'; +import { catchError, takeUntil } from 'rxjs/operators'; import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component'; @@ -33,6 +37,11 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit { public deviceType: string; public hasPermissionToCreateAccess: boolean; public hasPermissionToDeleteAccess: boolean; + public hasPermissionToUpdateOwnAccessToken: boolean; + public isAccessTokenHidden = true; + public updateOwnAccessTokenForm = this.formBuilder.group({ + accessToken: ['', Validators.required] + }); public user: User; private unsubscribeSubject = new Subject(); @@ -42,8 +51,11 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit { private dataService: DataService, private deviceService: DeviceDetectorService, private dialog: MatDialog, + private formBuilder: FormBuilder, + private notificationService: NotificationService, private route: ActivatedRoute, private router: Router, + private tokenStorageService: TokenStorageService, private userService: UserService ) { const { globalPermissions } = this.dataService.fetchInfo(); @@ -69,6 +81,11 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit { permissions.deleteAccess ); + this.hasPermissionToUpdateOwnAccessToken = hasPermission( + this.user.permissions, + permissions.updateOwnAccessToken + ); + this.changeDetectorRef.markForCheck(); } }); @@ -99,6 +116,41 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit { }); } + public onGenerateAccessToken() { + this.notificationService.confirm({ + confirmFn: () => { + this.dataService + .updateOwnAccessToken({ + accessToken: this.updateOwnAccessTokenForm.get('accessToken').value + }) + .pipe( + catchError(() => { + this.notificationService.alert({ + title: $localize`Oops! Incorrect Security Token.` + }); + + return EMPTY; + }), + takeUntil(this.unsubscribeSubject) + ) + .subscribe(({ accessToken }) => { + this.notificationService.alert({ + discardFn: () => { + this.tokenStorageService.signOut(); + this.userService.remove(); + + document.location.href = `/${document.documentElement.lang}`; + }, + message: accessToken, + title: $localize`Security token` + }); + }); + }, + confirmType: ConfirmationDialogType.Warn, + title: $localize`Do you really want to generate a new security token?` + }); + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); diff --git a/apps/client/src/app/components/user-account-access/user-account-access.html b/apps/client/src/app/components/user-account-access/user-account-access.html index efb918984..2979fd6fa 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.html +++ b/apps/client/src/app/components/user-account-access/user-account-access.html @@ -1,3 +1,53 @@ +@if (hasPermissionToUpdateOwnAccessToken) { +
+

Security Token

+
+
+ + Security Token + + + +
+ +
+
+
+
+} +
@if (accessesGet.length > 0) {

Received Access

diff --git a/apps/client/src/app/components/user-account-access/user-account-access.module.ts b/apps/client/src/app/components/user-account-access/user-account-access.module.ts index 93270ee3c..18bc77a08 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.module.ts +++ b/apps/client/src/app/components/user-account-access/user-account-access.module.ts @@ -2,9 +2,12 @@ import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/acce import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; import { RouterModule } from '@angular/router'; import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module'; @@ -20,7 +23,11 @@ import { UserAccountAccessComponent } from './user-account-access.component'; GfPremiumIndicatorComponent, MatButtonModule, MatDialogModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, RouterModule - ] + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class GfUserAccountAccessModule {} diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index ab36109cd..3ae0971c5 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -16,6 +16,7 @@ import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface'; import { DeleteOwnUserDto } from '@ghostfolio/api/app/user/delete-own-user.dto'; import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface'; +import { UpdateOwnAccessTokenDto } from '@ghostfolio/api/app/user/update-own-access-token.dto'; import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; @@ -703,13 +704,6 @@ export class DataService { return this.http.get('/api/v1/watchlist'); } - public generateAccessToken(aUserId: string) { - return this.http.post( - `/api/v1/user/${aUserId}/access-token`, - {} - ); - } - public loginAnonymous(accessToken: string) { return this.http.post('/api/v1/auth/anonymous', { accessToken @@ -818,6 +812,20 @@ export class DataService { }); } + public updateOwnAccessToken(aAccessToken: UpdateOwnAccessTokenDto) { + return this.http.post( + '/api/v1/user/access-token', + aAccessToken + ); + } + + public updateUserAccessToken(aUserId: string) { + return this.http.post( + `/api/v1/user/${aUserId}/access-token`, + {} + ); + } + public updateInfo() { this.http.get('/api/v1/info').subscribe((info) => { const utmSource = window.localStorage.getItem('utm_source') as diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 8f8a10427..1ad0bd760 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -52,6 +52,7 @@ export const permissions = { updateMarketData: 'updateMarketData', updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile', updateOrder: 'updateOrder', + updateOwnAccessToken: 'updateOwnAccessToken', updatePlatform: 'updatePlatform', updateTag: 'updateTag', updateUserSettings: 'updateUserSettings', @@ -81,7 +82,6 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteAccount, permissions.deleteAuthDevice, permissions.deleteOrder, - permissions.deleteOwnUser, permissions.deletePlatform, permissions.deleteTag, permissions.deleteUser, @@ -127,7 +127,6 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteAccountBalance, permissions.deleteAuthDevice, permissions.deleteOrder, - permissions.deleteOwnUser, permissions.deleteWatchlistItem, permissions.readAiPrompt, permissions.readMarketDataOfOwnAssetProfile,