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>
This commit is contained in:
csehatt741 2025-03-22 10:56:01 +01:00 committed by GitHub
parent 6c624fefc9
commit 4842c347a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 123 additions and 29 deletions

View File

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

View File

@ -20,10 +20,10 @@ export class AuthService {
public async validateAnonymousLogin(accessToken: string): Promise<string> {
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 }

View File

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

View File

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

View File

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

View File

@ -239,8 +239,17 @@
<span i18n>Impersonate User</span>
</span>
</button>
<hr class="m-0" />
}
<button
mat-menu-item
(click)="onGenerateAccessToken(element.id)"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="key-outline" />
<span i18n>Generate Security Token</span>
</span>
</button>
<hr class="m-0" />
<button
mat-menu-item
[disabled]="element.id === user?.id"

View File

@ -22,6 +22,7 @@ import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
Access,
AccessTokenResponse,
AccountBalancesResponse,
Accounts,
AiPromptResponse,
@ -685,6 +686,13 @@ export class DataService {
return this.http.get<Tag[]>('/api/v1/tags');
}
public generateAccessToken(aUserId: string) {
return this.http.post<AccessTokenResponse>(
`/api/v1/user/${aUserId}/access-token`,
{}
);
}
public loginAnonymous(accessToken: string) {
return this.http.post<OAuthResponse>('/api/v1/auth/anonymous', {
accessToken

View File

@ -1,5 +1,3 @@
import { SubscriptionInterstitialDialogParams } from '@ghostfolio/client/components/subscription-interstitial-dialog/interfaces/interfaces';
import { SubscriptionInterstitialDialog } from '@ghostfolio/client/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component';
import { Filter, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -13,6 +11,8 @@ import { Observable, Subject, of } from 'rxjs';
import { throwError } from 'rxjs';
import { catchError, map, takeUntil } from 'rxjs/operators';
import { SubscriptionInterstitialDialogParams } from '../../components/subscription-interstitial-dialog/interfaces/interfaces';
import { SubscriptionInterstitialDialog } from '../../components/subscription-interstitial-dialog/subscription-interstitial-dialog.component';
import { UserStoreActions } from './user-store.actions';
import { UserStoreState } from './user-store.state';

View File

@ -38,6 +38,7 @@ import type { PortfolioReportRule } from './portfolio-report-rule.interface';
import type { PortfolioSummary } from './portfolio-summary.interface';
import type { Position } from './position.interface';
import type { Product } from './product';
import type { AccessTokenResponse } from './responses/access-token-response.interface';
import type { AccountBalancesResponse } from './responses/account-balances-response.interface';
import type { AiPromptResponse } from './responses/ai-prompt-response.interface';
import type { ApiKeyResponse } from './responses/api-key-response.interface';
@ -69,6 +70,7 @@ import type { XRayRulesSettings } from './x-ray-rules-settings.interface';
export {
Access,
AccessTokenResponse,
AccountBalance,
AccountBalancesResponse,
Accounts,

View File

@ -0,0 +1,3 @@
export interface AccessTokenResponse {
accessToken: string;
}