Browse Source

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

* Allow user to rotate Security Token

* Update changelog
pull/5056/head
csehatt741 2 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. 84
      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
### 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

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

84
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<UserModel> {
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<AccessTokenResponse> {
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<AccessTokenResponse> {
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<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);
if (user.provider === 'ANONYMOUS') {
currentPermissions.push(permissions.deleteOwnUser);
currentPermissions.push(permissions.updateOwnAccessToken);
}
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
// currentPermissions = without(
// 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({
confirmFn: () => {
this.dataService
.generateAccessToken(aUserId)
.updateUserAccessToken(aUserId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accessToken }) => {
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 { 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<void>();
@ -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();

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">
@if (accessesGet.length > 0) {
<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 { 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 {}

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 { 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<WatchlistResponse>('/api/v1/watchlist');
}
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
@ -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() {
this.http.get<InfoItem>('/api/v1/info').subscribe((info) => {
const utmSource = window.localStorage.getItem('utm_source') as

3
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,

Loading…
Cancel
Save