Browse Source

Feature/allow user to rotate Security Token (#5016)

* Allow user to rotate Security Token

* Update changelog
pull/5056/head
csehatt741 3 days ago
committed by GitHub
parent
commit
c437bc2534
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 6
      apps/api/src/app/user/update-own-access-token.dto.ts
  3. 82
      apps/api/src/app/user/user.controller.ts
  4. 5
      apps/api/src/app/user/user.service.ts
  5. 2
      apps/client/src/app/components/admin-users/admin-users.component.ts
  6. 56
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  7. 50
      apps/client/src/app/components/user-account-access/user-account-access.html
  8. 11
      apps/client/src/app/components/user-account-access/user-account-access.module.ts
  9. 22
      apps/client/src/app/services/data.service.ts
  10. 3
      libs/common/src/lib/permissions.ts

6
CHANGELOG.md

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added support for generating a new _Security Token_ via the user’s account access panel
### Changed ### Changed
- Renamed `Account` to `account` in the `Order` database schema - 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 - Changed the navigation to always show the portfolio page
- Migrated the data type of currencies from `enum` to `string` in the database - Migrated the data type of currencies from `enum` to `string` in the database
- Supported unlimited currencies (instead of `CHF`, `EUR`, `GBP` and `USD`) - 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 ### Fixed

6
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;
}

82
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 { DeleteOwnUserDto } from './delete-own-user.dto';
import { UserItem } from './interfaces/user-item.interface'; import { UserItem } from './interfaces/user-item.interface';
import { UpdateOwnAccessTokenDto } from './update-own-access-token.dto';
import { UpdateUserSettingDto } from './update-user-setting.dto'; import { UpdateUserSettingDto } from './update-user-setting.dto';
import { UserService } from './user.service'; import { UserService } from './user.service';
@ -53,24 +54,12 @@ export class UserController {
public async deleteOwnUser( public async deleteOwnUser(
@Body() data: DeleteOwnUserDto @Body() data: DeleteOwnUserDto
): Promise<UserModel> { ): Promise<UserModel> {
const hashedAccessToken = this.userService.createAccessToken({ const user = await this.validateAccessToken(
password: data.accessToken, data.accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT') this.request.user.id
});
const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken, id: this.request.user.id }
});
if (!user) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
); );
}
return this.userService.deleteUser({ return this.userService.deleteUser({
accessToken: hashedAccessToken,
id: user.id id: user.id
}); });
} }
@ -94,20 +83,24 @@ export class UserController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post(':id/access-token') @Post(':id/access-token')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async generateAccessToken( public async updateUserAccessToken(
@Param('id') id: string @Param('id') id: string
): Promise<AccessTokenResponse> { ): Promise<AccessTokenResponse> {
const { accessToken, hashedAccessToken } = return this.rotateUserAccessToken(id);
this.userService.generateAccessToken({ }
userId: id
});
await this.prismaService.user.update({ @HasPermission(permissions.updateOwnAccessToken)
data: { accessToken: hashedAccessToken }, @Post('access-token')
where: { id } @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
}); public async updateOwnAccessToken(
@Body() data: UpdateOwnAccessTokenDto
): Promise<AccessTokenResponse> {
const user = await this.validateAccessToken(
data.accessToken,
this.request.user.id
);
return { accessToken }; return this.rotateUserAccessToken(user.id);
} }
@Get() @Get()
@ -189,4 +182,43 @@ export class UserController {
userId: this.request.user.id userId: this.request.user.id
}); });
} }
private async rotateUserAccessToken(
userId: string
): Promise<AccessTokenResponse> {
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<UserModel> {
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;
}
} }

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

@ -354,6 +354,11 @@ export class UserService {
let currentPermissions = getPermissions(user.role); let currentPermissions = getPermissions(user.role);
if (user.provider === 'ANONYMOUS') {
currentPermissions.push(permissions.deleteOwnUser);
currentPermissions.push(permissions.updateOwnAccessToken);
}
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) { if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
// currentPermissions = without( // currentPermissions = without(
// currentPermissions, // currentPermissions,

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

@ -147,7 +147,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
this.dataService this.dataService
.generateAccessToken(aUserId) .updateUserAccessToken(aUserId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accessToken }) => { .subscribe(({ accessToken }) => {
this.notificationService.alert({ this.notificationService.alert({

56
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 { 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 { 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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -11,11 +14,12 @@ import {
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component'; 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 deviceType: string;
public hasPermissionToCreateAccess: boolean; public hasPermissionToCreateAccess: boolean;
public hasPermissionToDeleteAccess: boolean; public hasPermissionToDeleteAccess: boolean;
public hasPermissionToUpdateOwnAccessToken: boolean;
public isAccessTokenHidden = true;
public updateOwnAccessTokenForm = this.formBuilder.group({
accessToken: ['', Validators.required]
});
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -42,8 +51,11 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private formBuilder: FormBuilder,
private notificationService: NotificationService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private tokenStorageService: TokenStorageService,
private userService: UserService private userService: UserService
) { ) {
const { globalPermissions } = this.dataService.fetchInfo(); const { globalPermissions } = this.dataService.fetchInfo();
@ -69,6 +81,11 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit {
permissions.deleteAccess permissions.deleteAccess
); );
this.hasPermissionToUpdateOwnAccessToken = hasPermission(
this.user.permissions,
permissions.updateOwnAccessToken
);
this.changeDetectorRef.markForCheck(); 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() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

50
apps/client/src/app/components/user-account-access/user-account-access.html

@ -1,3 +1,53 @@
@if (hasPermissionToUpdateOwnAccessToken) {
<div class="container">
<h1 class="h3 mb-3 text-center" i18n>Security Token</h1>
<form
class="w-100"
[formGroup]="updateOwnAccessTokenForm"
(ngSubmit)="onGenerateAccessToken()"
>
<div class="align-items-center d-flex justify-content-center mb-5">
<mat-form-field
appearance="outline"
class="without-hint w-50"
[hideRequiredMarker]="true"
>
<mat-label i18n>Security Token</mat-label>
<input
formControlName="accessToken"
matInput
[type]="isAccessTokenHidden ? 'password' : 'text'"
/>
<button
mat-button
matSuffix
type="button"
(click)="isAccessTokenHidden = !isAccessTokenHidden"
>
<ion-icon
[name]="isAccessTokenHidden ? 'eye-outline' : 'eye-off-outline'"
/>
</button>
</mat-form-field>
<div class="pl-2">
<button
color="warn"
mat-flat-button
type="submit"
[disabled]="
!(
updateOwnAccessTokenForm.dirty && updateOwnAccessTokenForm.valid
)
"
>
<span i18n>Generate</span>
</button>
</div>
</div>
</form>
</div>
}
<div class="container"> <div class="container">
@if (accessesGet.length > 0) { @if (accessesGet.length > 0) {
<h1 class="h3 mb-3 text-center" i18n>Received Access</h1> <h1 class="h3 mb-3 text-center" i18n>Received Access</h1>

11
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 { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common'; 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 { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; 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 { RouterModule } from '@angular/router';
import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module'; 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, GfPremiumIndicatorComponent,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
RouterModule RouterModule
] ],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfUserAccountAccessModule {} export class GfUserAccountAccessModule {}

22
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 { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
import { DeleteOwnUserDto } from '@ghostfolio/api/app/user/delete-own-user.dto'; import { DeleteOwnUserDto } from '@ghostfolio/api/app/user/delete-own-user.dto';
import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface'; 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 { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
@ -703,13 +704,6 @@ export class DataService {
return this.http.get<WatchlistResponse>('/api/v1/watchlist'); return this.http.get<WatchlistResponse>('/api/v1/watchlist');
} }
public generateAccessToken(aUserId: string) {
return this.http.post<AccessTokenResponse>(
`/api/v1/user/${aUserId}/access-token`,
{}
);
}
public loginAnonymous(accessToken: string) { public loginAnonymous(accessToken: string) {
return this.http.post<OAuthResponse>('/api/v1/auth/anonymous', { return this.http.post<OAuthResponse>('/api/v1/auth/anonymous', {
accessToken accessToken
@ -818,6 +812,20 @@ export class DataService {
}); });
} }
public updateOwnAccessToken(aAccessToken: UpdateOwnAccessTokenDto) {
return this.http.post<AccessTokenResponse>(
'/api/v1/user/access-token',
aAccessToken
);
}
public updateUserAccessToken(aUserId: string) {
return this.http.post<AccessTokenResponse>(
`/api/v1/user/${aUserId}/access-token`,
{}
);
}
public updateInfo() { public updateInfo() {
this.http.get<InfoItem>('/api/v1/info').subscribe((info) => { this.http.get<InfoItem>('/api/v1/info').subscribe((info) => {
const utmSource = window.localStorage.getItem('utm_source') as const utmSource = window.localStorage.getItem('utm_source') as

3
libs/common/src/lib/permissions.ts

@ -52,6 +52,7 @@ export const permissions = {
updateMarketData: 'updateMarketData', updateMarketData: 'updateMarketData',
updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile', updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile',
updateOrder: 'updateOrder', updateOrder: 'updateOrder',
updateOwnAccessToken: 'updateOwnAccessToken',
updatePlatform: 'updatePlatform', updatePlatform: 'updatePlatform',
updateTag: 'updateTag', updateTag: 'updateTag',
updateUserSettings: 'updateUserSettings', updateUserSettings: 'updateUserSettings',
@ -81,7 +82,6 @@ export function getPermissions(aRole: Role): string[] {
permissions.deleteAccount, permissions.deleteAccount,
permissions.deleteAuthDevice, permissions.deleteAuthDevice,
permissions.deleteOrder, permissions.deleteOrder,
permissions.deleteOwnUser,
permissions.deletePlatform, permissions.deletePlatform,
permissions.deleteTag, permissions.deleteTag,
permissions.deleteUser, permissions.deleteUser,
@ -127,7 +127,6 @@ export function getPermissions(aRole: Role): string[] {
permissions.deleteAccountBalance, permissions.deleteAccountBalance,
permissions.deleteAuthDevice, permissions.deleteAuthDevice,
permissions.deleteOrder, permissions.deleteOrder,
permissions.deleteOwnUser,
permissions.deleteWatchlistItem, permissions.deleteWatchlistItem,
permissions.readAiPrompt, permissions.readAiPrompt,
permissions.readMarketDataOfOwnAssetProfile, permissions.readMarketDataOfOwnAssetProfile,

Loading…
Cancel
Save