Skip to content

Commit 4842c34

Browse files
csehatt741dtslvr
andauthored
Feature/generate new security token for user via admin control panel (ghostfolio#4458)
* Generate new security token for user via admin control panel * Update changelog --------- Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
1 parent 6c624fe commit 4842c34

File tree

10 files changed

+123
-29
lines changed

10 files changed

+123
-29
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- Added support for filtering in the _Copy AI prompt to clipboard_ actions on the analysis page (experimental)
13+
- Added support for generating a new _Security Token_ via the users table of the admin control panel
1314
- Added an endpoint to localize the `site.webmanifest`
1415
- Added the _Storybook_ path to the `sitemap.xml` file
1516

apps/api/src/app/auth/auth.service.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ export class AuthService {
2020
public async validateAnonymousLogin(accessToken: string): Promise<string> {
2121
return new Promise(async (resolve, reject) => {
2222
try {
23-
const hashedAccessToken = this.userService.createAccessToken(
24-
accessToken,
25-
this.configurationService.get('ACCESS_TOKEN_SALT')
26-
);
23+
const hashedAccessToken = this.userService.createAccessToken({
24+
password: accessToken,
25+
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
26+
});
2727

2828
const [user] = await this.userService.users({
2929
where: { accessToken: hashedAccessToken }

apps/api/src/app/user/user.controller.ts

+30-5
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
22
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
33
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
4+
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
45
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
5-
import { User, UserSettings } from '@ghostfolio/common/interfaces';
6+
import {
7+
AccessTokenResponse,
8+
User,
9+
UserSettings
10+
} from '@ghostfolio/common/interfaces';
611
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
712
import type { RequestWithUser } from '@ghostfolio/common/types';
813

@@ -36,6 +41,7 @@ export class UserController {
3641
public constructor(
3742
private readonly configurationService: ConfigurationService,
3843
private readonly jwtService: JwtService,
44+
private readonly prismaService: PrismaService,
3945
private readonly propertyService: PropertyService,
4046
@Inject(REQUEST) private readonly request: RequestWithUser,
4147
private readonly userService: UserService
@@ -47,10 +53,10 @@ export class UserController {
4753
public async deleteOwnUser(
4854
@Body() data: DeleteOwnUserDto
4955
): Promise<UserModel> {
50-
const hashedAccessToken = this.userService.createAccessToken(
51-
data.accessToken,
52-
this.configurationService.get('ACCESS_TOKEN_SALT')
53-
);
56+
const hashedAccessToken = this.userService.createAccessToken({
57+
password: data.accessToken,
58+
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
59+
});
5460

5561
const [user] = await this.userService.users({
5662
where: { accessToken: hashedAccessToken, id: this.request.user.id }
@@ -85,6 +91,25 @@ export class UserController {
8591
});
8692
}
8793

94+
@HasPermission(permissions.accessAdminControl)
95+
@Post(':id/access-token')
96+
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
97+
public async generateAccessToken(
98+
@Param('id') id: string
99+
): Promise<AccessTokenResponse> {
100+
const { accessToken, hashedAccessToken } =
101+
this.userService.generateAccessToken({
102+
userId: id
103+
});
104+
105+
await this.prismaService.user.update({
106+
data: { accessToken: hashedAccessToken },
107+
where: { id }
108+
});
109+
110+
return { accessToken };
111+
}
112+
88113
@Get()
89114
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
90115
public async getUser(

apps/api/src/app/user/user.service.ts

+26-9
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,33 @@ export class UserService {
6767
return this.prismaService.user.count(args);
6868
}
6969

70-
public createAccessToken(password: string, salt: string): string {
70+
public createAccessToken({
71+
password,
72+
salt
73+
}: {
74+
password: string;
75+
salt: string;
76+
}): string {
7177
const hash = createHmac('sha512', salt);
7278
hash.update(password);
7379

7480
return hash.digest('hex');
7581
}
7682

83+
public generateAccessToken({ userId }: { userId: string }) {
84+
const accessToken = this.createAccessToken({
85+
password: userId,
86+
salt: getRandomString(10)
87+
});
88+
89+
const hashedAccessToken = this.createAccessToken({
90+
password: accessToken,
91+
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
92+
});
93+
94+
return { accessToken, hashedAccessToken };
95+
}
96+
7797
public async getUser(
7898
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
7999
aLocale = locale
@@ -433,7 +453,7 @@ export class UserService {
433453
data.provider = 'ANONYMOUS';
434454
}
435455

436-
let user = await this.prismaService.user.create({
456+
const user = await this.prismaService.user.create({
437457
data: {
438458
...data,
439459
Account: {
@@ -464,14 +484,11 @@ export class UserService {
464484
}
465485

466486
if (data.provider === 'ANONYMOUS') {
467-
const accessToken = this.createAccessToken(user.id, getRandomString(10));
468-
469-
const hashedAccessToken = this.createAccessToken(
470-
accessToken,
471-
this.configurationService.get('ACCESS_TOKEN_SALT')
472-
);
487+
const { accessToken, hashedAccessToken } = this.generateAccessToken({
488+
userId: user.id
489+
});
473490

474-
user = await this.prismaService.user.update({
491+
await this.prismaService.user.update({
475492
data: { accessToken: hashedAccessToken },
476493
where: { id: user.id }
477494
});

apps/client/src/app/components/admin-users/admin-users.component.ts

+37-8
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
2-
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
3-
import { AdminService } from '@ghostfolio/client/services/admin.service';
4-
import { DataService } from '@ghostfolio/client/services/data.service';
5-
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
6-
import { UserService } from '@ghostfolio/client/services/user/user.service';
1+
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
72
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
83
import { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper';
94
import { AdminUsers, InfoItem, User } from '@ghostfolio/common/interfaces';
@@ -26,11 +21,18 @@ import {
2621
import { Subject } from 'rxjs';
2722
import { takeUntil } from 'rxjs/operators';
2823

24+
import { ConfirmationDialogType } from '../../core/notification/confirmation-dialog/confirmation-dialog.type';
25+
import { NotificationService } from '../../core/notification/notification.service';
26+
import { AdminService } from '../../services/admin.service';
27+
import { DataService } from '../../services/data.service';
28+
import { ImpersonationStorageService } from '../../services/impersonation-storage.service';
29+
import { UserService } from '../../services/user/user.service';
30+
2931
@Component({
3032
selector: 'gf-admin-users',
33+
standalone: false,
3134
styleUrls: ['./admin-users.scss'],
32-
templateUrl: './admin-users.html',
33-
standalone: false
35+
templateUrl: './admin-users.html'
3436
})
3537
export class AdminUsersComponent implements OnDestroy, OnInit {
3638
@ViewChild(MatPaginator) paginator: MatPaginator;
@@ -55,6 +57,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
5557
private dataService: DataService,
5658
private impersonationStorageService: ImpersonationStorageService,
5759
private notificationService: NotificationService,
60+
private tokenStorageService: TokenStorageService,
5861
private userService: UserService
5962
) {
6063
this.info = this.dataService.fetchInfo();
@@ -140,6 +143,32 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
140143
});
141144
}
142145

146+
public onGenerateAccessToken(aUserId: string) {
147+
this.notificationService.confirm({
148+
confirmFn: () => {
149+
this.dataService
150+
.generateAccessToken(aUserId)
151+
.pipe(takeUntil(this.unsubscribeSubject))
152+
.subscribe(({ accessToken }) => {
153+
this.notificationService.alert({
154+
discardFn: () => {
155+
if (aUserId === this.user.id) {
156+
this.tokenStorageService.signOut();
157+
this.userService.remove();
158+
159+
document.location.href = `/${document.documentElement.lang}`;
160+
}
161+
},
162+
message: accessToken,
163+
title: $localize`Security token`
164+
});
165+
});
166+
},
167+
confirmType: ConfirmationDialogType.Warn,
168+
title: $localize`Do you really want to generate a new security token for this user?`
169+
});
170+
}
171+
143172
public onImpersonateUser(aId: string) {
144173
if (aId) {
145174
this.impersonationStorageService.setId(aId);

apps/client/src/app/components/admin-users/admin-users.html

+10-1
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,17 @@
239239
<span i18n>Impersonate User</span>
240240
</span>
241241
</button>
242-
<hr class="m-0" />
243242
}
243+
<button
244+
mat-menu-item
245+
(click)="onGenerateAccessToken(element.id)"
246+
>
247+
<span class="align-items-center d-flex">
248+
<ion-icon class="mr-2" name="key-outline" />
249+
<span i18n>Generate Security Token</span>
250+
</span>
251+
</button>
252+
<hr class="m-0" />
244253
<button
245254
mat-menu-item
246255
[disabled]="element.id === user?.id"

apps/client/src/app/services/data.service.ts

+8
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
2222
import { DATE_FORMAT } from '@ghostfolio/common/helper';
2323
import {
2424
Access,
25+
AccessTokenResponse,
2526
AccountBalancesResponse,
2627
Accounts,
2728
AiPromptResponse,
@@ -685,6 +686,13 @@ export class DataService {
685686
return this.http.get<Tag[]>('/api/v1/tags');
686687
}
687688

689+
public generateAccessToken(aUserId: string) {
690+
return this.http.post<AccessTokenResponse>(
691+
`/api/v1/user/${aUserId}/access-token`,
692+
{}
693+
);
694+
}
695+
688696
public loginAnonymous(accessToken: string) {
689697
return this.http.post<OAuthResponse>('/api/v1/auth/anonymous', {
690698
accessToken

apps/client/src/app/services/user/user.service.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { SubscriptionInterstitialDialogParams } from '@ghostfolio/client/components/subscription-interstitial-dialog/interfaces/interfaces';
2-
import { SubscriptionInterstitialDialog } from '@ghostfolio/client/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component';
31
import { Filter, User } from '@ghostfolio/common/interfaces';
42
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
53

@@ -13,6 +11,8 @@ import { Observable, Subject, of } from 'rxjs';
1311
import { throwError } from 'rxjs';
1412
import { catchError, map, takeUntil } from 'rxjs/operators';
1513

14+
import { SubscriptionInterstitialDialogParams } from '../../components/subscription-interstitial-dialog/interfaces/interfaces';
15+
import { SubscriptionInterstitialDialog } from '../../components/subscription-interstitial-dialog/subscription-interstitial-dialog.component';
1616
import { UserStoreActions } from './user-store.actions';
1717
import { UserStoreState } from './user-store.state';
1818

libs/common/src/lib/interfaces/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import type { PortfolioReportRule } from './portfolio-report-rule.interface';
3838
import type { PortfolioSummary } from './portfolio-summary.interface';
3939
import type { Position } from './position.interface';
4040
import type { Product } from './product';
41+
import type { AccessTokenResponse } from './responses/access-token-response.interface';
4142
import type { AccountBalancesResponse } from './responses/account-balances-response.interface';
4243
import type { AiPromptResponse } from './responses/ai-prompt-response.interface';
4344
import type { ApiKeyResponse } from './responses/api-key-response.interface';
@@ -69,6 +70,7 @@ import type { XRayRulesSettings } from './x-ray-rules-settings.interface';
6970

7071
export {
7172
Access,
73+
AccessTokenResponse,
7274
AccountBalance,
7375
AccountBalancesResponse,
7476
Accounts,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export interface AccessTokenResponse {
2+
accessToken: string;
3+
}

0 commit comments

Comments
 (0)