From 4842c347a9c893b3629ce0749bb71243e6989129 Mon Sep 17 00:00:00 2001 From: csehatt741 <77381875+csehatt741@users.noreply.github.com> Date: Sat, 22 Mar 2025 10:56:01 +0100 Subject: [PATCH] Feature/generate new security token for user via admin control panel (#4458) * Generate new security token for user via admin control panel * Update changelog --------- Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> --- CHANGELOG.md | 1 + apps/api/src/app/auth/auth.service.ts | 8 ++-- apps/api/src/app/user/user.controller.ts | 35 ++++++++++++--- apps/api/src/app/user/user.service.ts | 35 +++++++++++---- .../admin-users/admin-users.component.ts | 45 +++++++++++++++---- .../components/admin-users/admin-users.html | 11 ++++- apps/client/src/app/services/data.service.ts | 8 ++++ .../src/app/services/user/user.service.ts | 4 +- libs/common/src/lib/interfaces/index.ts | 2 + .../access-token-response.interface.ts | 3 ++ 10 files changed, 123 insertions(+), 29 deletions(-) create mode 100644 libs/common/src/lib/interfaces/responses/access-token-response.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b69880..8ec1fd9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added support for filtering in the _Copy AI prompt to clipboard_ actions on the analysis page (experimental) +- Added support for generating a new _Security Token_ via the users table of the admin control panel - Added an endpoint to localize the `site.webmanifest` - Added the _Storybook_ path to the `sitemap.xml` file diff --git a/apps/api/src/app/auth/auth.service.ts b/apps/api/src/app/auth/auth.service.ts index edfb22b6..ceff492a 100644 --- a/apps/api/src/app/auth/auth.service.ts +++ b/apps/api/src/app/auth/auth.service.ts @@ -20,10 +20,10 @@ export class AuthService { public async validateAnonymousLogin(accessToken: string): Promise { return new Promise(async (resolve, reject) => { try { - const hashedAccessToken = this.userService.createAccessToken( - accessToken, - this.configurationService.get('ACCESS_TOKEN_SALT') - ); + const hashedAccessToken = this.userService.createAccessToken({ + password: accessToken, + salt: this.configurationService.get('ACCESS_TOKEN_SALT') + }); const [user] = await this.userService.users({ where: { accessToken: hashedAccessToken } diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 149e0628..868af505 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -1,8 +1,13 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; -import { User, UserSettings } from '@ghostfolio/common/interfaces'; +import { + AccessTokenResponse, + User, + UserSettings +} from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -36,6 +41,7 @@ export class UserController { public constructor( private readonly configurationService: ConfigurationService, private readonly jwtService: JwtService, + private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService @@ -47,10 +53,10 @@ export class UserController { public async deleteOwnUser( @Body() data: DeleteOwnUserDto ): Promise { - const hashedAccessToken = this.userService.createAccessToken( - data.accessToken, - this.configurationService.get('ACCESS_TOKEN_SALT') - ); + 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 } @@ -85,6 +91,25 @@ export class UserController { }); } + @HasPermission(permissions.accessAdminControl) + @Post(':id/access-token') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async generateAccessToken( + @Param('id') id: string + ): Promise { + const { accessToken, hashedAccessToken } = + this.userService.generateAccessToken({ + userId: id + }); + + await this.prismaService.user.update({ + data: { accessToken: hashedAccessToken }, + where: { id } + }); + + return { accessToken }; + } + @Get() @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getUser( diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 40bc1b2b..e9b8078b 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -67,13 +67,33 @@ export class UserService { return this.prismaService.user.count(args); } - public createAccessToken(password: string, salt: string): string { + public createAccessToken({ + password, + salt + }: { + password: string; + salt: string; + }): string { const hash = createHmac('sha512', salt); hash.update(password); return hash.digest('hex'); } + public generateAccessToken({ userId }: { userId: string }) { + const accessToken = this.createAccessToken({ + password: userId, + salt: getRandomString(10) + }); + + const hashedAccessToken = this.createAccessToken({ + password: accessToken, + salt: this.configurationService.get('ACCESS_TOKEN_SALT') + }); + + return { accessToken, hashedAccessToken }; + } + public async getUser( { Account, id, permissions, Settings, subscription }: UserWithSettings, aLocale = locale @@ -433,7 +453,7 @@ export class UserService { data.provider = 'ANONYMOUS'; } - let user = await this.prismaService.user.create({ + const user = await this.prismaService.user.create({ data: { ...data, Account: { @@ -464,14 +484,11 @@ export class UserService { } if (data.provider === 'ANONYMOUS') { - const accessToken = this.createAccessToken(user.id, getRandomString(10)); + const { accessToken, hashedAccessToken } = this.generateAccessToken({ + userId: user.id + }); - const hashedAccessToken = this.createAccessToken( - accessToken, - this.configurationService.get('ACCESS_TOKEN_SALT') - ); - - user = await this.prismaService.user.update({ + await this.prismaService.user.update({ data: { accessToken: hashedAccessToken }, where: { id: user.id } }); 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 50b8cb5f..e1cd3102 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 @@ -1,9 +1,4 @@ -import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type'; -import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; -import { AdminService } from '@ghostfolio/client/services/admin.service'; -import { DataService } from '@ghostfolio/client/services/data.service'; -import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; -import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper'; import { AdminUsers, InfoItem, User } from '@ghostfolio/common/interfaces'; @@ -26,11 +21,18 @@ import { import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; +import { ConfirmationDialogType } from '../../core/notification/confirmation-dialog/confirmation-dialog.type'; +import { NotificationService } from '../../core/notification/notification.service'; +import { AdminService } from '../../services/admin.service'; +import { DataService } from '../../services/data.service'; +import { ImpersonationStorageService } from '../../services/impersonation-storage.service'; +import { UserService } from '../../services/user/user.service'; + @Component({ selector: 'gf-admin-users', + standalone: false, styleUrls: ['./admin-users.scss'], - templateUrl: './admin-users.html', - standalone: false + templateUrl: './admin-users.html' }) export class AdminUsersComponent implements OnDestroy, OnInit { @ViewChild(MatPaginator) paginator: MatPaginator; @@ -55,6 +57,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit { private dataService: DataService, private impersonationStorageService: ImpersonationStorageService, private notificationService: NotificationService, + private tokenStorageService: TokenStorageService, private userService: UserService ) { this.info = this.dataService.fetchInfo(); @@ -140,6 +143,32 @@ export class AdminUsersComponent implements OnDestroy, OnInit { }); } + public onGenerateAccessToken(aUserId: string) { + this.notificationService.confirm({ + confirmFn: () => { + this.dataService + .generateAccessToken(aUserId) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ accessToken }) => { + this.notificationService.alert({ + discardFn: () => { + if (aUserId === this.user.id) { + 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 for this user?` + }); + } + public onImpersonateUser(aId: string) { if (aId) { this.impersonationStorageService.setId(aId); diff --git a/apps/client/src/app/components/admin-users/admin-users.html b/apps/client/src/app/components/admin-users/admin-users.html index ca8ef055..e8725e70 100644 --- a/apps/client/src/app/components/admin-users/admin-users.html +++ b/apps/client/src/app/components/admin-users/admin-users.html @@ -239,8 +239,17 @@ Impersonate User -
} + +