From 57814dbd41bcf2ef79ff7b2bc7d5bbeebb47c63e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 1 Dec 2024 16:27:14 +0100 Subject: [PATCH] Setup API keys for Ghostfolio data provider --- apps/api/src/app/app.module.ts | 2 + .../endpoints/api-keys/api-keys.controller.ts | 25 ++++++++++ .../app/endpoints/api-keys/api-keys.module.ts | 11 +++++ apps/api/src/app/user/user.service.ts | 21 ++------- apps/api/src/helper/string.helper.ts | 14 ++++++ .../src/services/api-key/api-key.module.ts | 12 +++++ .../src/services/api-key/api-key.service.ts | 43 +++++++++++++++++ .../user-account-membership.component.ts | 46 ++++++++++++++++++- .../user-account-membership.html | 2 + apps/client/src/app/services/data.service.ts | 23 ++++++---- libs/common/src/lib/interfaces/index.ts | 2 + .../responses/api-key-response.interface.ts | 3 ++ libs/common/src/lib/permissions.ts | 1 + .../membership-card.component.html | 19 ++++++++ .../membership-card.component.scss | 6 +++ .../membership-card.component.ts | 17 ++++++- 16 files changed, 217 insertions(+), 30 deletions(-) create mode 100644 apps/api/src/app/endpoints/api-keys/api-keys.controller.ts create mode 100644 apps/api/src/app/endpoints/api-keys/api-keys.module.ts create mode 100644 apps/api/src/helper/string.helper.ts create mode 100644 apps/api/src/services/api-key/api-key.module.ts create mode 100644 apps/api/src/services/api-key/api-key.service.ts create mode 100644 libs/common/src/lib/interfaces/responses/api-key-response.interface.ts diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 4fbdafb08..b1a240235 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -31,6 +31,7 @@ import { AuthDeviceModule } from './auth-device/auth-device.module'; import { AuthModule } from './auth/auth.module'; import { BenchmarkModule } from './benchmark/benchmark.module'; import { CacheModule } from './cache/cache.module'; +import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { PublicModule } from './endpoints/public/public.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; @@ -55,6 +56,7 @@ import { UserModule } from './user/user.module'; AdminModule, AccessModule, AccountModule, + ApiKeysModule, AssetModule, AuthDeviceModule, AuthModule, diff --git a/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts b/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts new file mode 100644 index 000000000..cbc68df93 --- /dev/null +++ b/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts @@ -0,0 +1,25 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; +import { ApiKeyResponse } from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; + +@Controller('api-keys') +export class ApiKeysController { + public constructor( + private readonly apiKeyService: ApiKeyService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @HasPermission(permissions.createApiKey) + @Post() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async createApiKey(): Promise { + return this.apiKeyService.create({ userId: this.request.user.id }); + } +} diff --git a/apps/api/src/app/endpoints/api-keys/api-keys.module.ts b/apps/api/src/app/endpoints/api-keys/api-keys.module.ts new file mode 100644 index 000000000..123f11854 --- /dev/null +++ b/apps/api/src/app/endpoints/api-keys/api-keys.module.ts @@ -0,0 +1,11 @@ +import { ApiKeyModule } from '@ghostfolio/api/services/api-key/api-key.module'; + +import { Module } from '@nestjs/common'; + +import { ApiKeysController } from './api-keys.controller'; + +@Module({ + controllers: [ApiKeysController], + imports: [ApiKeyModule] +}) +export class ApiKeysModule {} diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 54dafda22..611a28721 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -2,6 +2,7 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { environment } from '@ghostfolio/api/environments/environment'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; +import { getRandomString } from '@ghostfolio/api/helper/string.helper'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; @@ -309,6 +310,8 @@ export class UserService { // Reset holdings view mode user.Settings.settings.holdingsViewMode = undefined; } else if (user.subscription?.type === 'Premium') { + // TODO + // currentPermissions.push(permissions.createApiKey); currentPermissions.push(permissions.enableDataProviderGhostfolio); currentPermissions.push(permissions.reportDataGlitch); @@ -408,10 +411,7 @@ export class UserService { } if (data.provider === 'ANONYMOUS') { - const accessToken = this.createAccessToken( - user.id, - this.getRandomString(10) - ); + const accessToken = this.createAccessToken(user.id, getRandomString(10)); const hashedAccessToken = this.createAccessToken( accessToken, @@ -528,17 +528,4 @@ export class UserService { return settings; } - - private getRandomString(length: number) { - const bytes = crypto.randomBytes(length); - const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - const result = []; - - for (let i = 0; i < length; i++) { - const randomByte = bytes[i]; - result.push(characters[randomByte % characters.length]); - } - - return result.join(''); - } } diff --git a/apps/api/src/helper/string.helper.ts b/apps/api/src/helper/string.helper.ts new file mode 100644 index 000000000..294f1f1a1 --- /dev/null +++ b/apps/api/src/helper/string.helper.ts @@ -0,0 +1,14 @@ +const crypto = require('crypto'); + +export function getRandomString(length: number) { + const bytes = crypto.randomBytes(length); + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const result = []; + + for (let i = 0; i < length; i++) { + const randomByte = bytes[i]; + result.push(characters[randomByte % characters.length]); + } + + return result.join(''); +} diff --git a/apps/api/src/services/api-key/api-key.module.ts b/apps/api/src/services/api-key/api-key.module.ts new file mode 100644 index 000000000..8681e3ad7 --- /dev/null +++ b/apps/api/src/services/api-key/api-key.module.ts @@ -0,0 +1,12 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +import { ApiKeyService } from './api-key.service'; + +@Module({ + exports: [ApiKeyService], + imports: [PrismaModule], + providers: [ApiKeyService] +}) +export class ApiKeyModule {} diff --git a/apps/api/src/services/api-key/api-key.service.ts b/apps/api/src/services/api-key/api-key.service.ts new file mode 100644 index 000000000..c74a90de9 --- /dev/null +++ b/apps/api/src/services/api-key/api-key.service.ts @@ -0,0 +1,43 @@ +import { getRandomString } from '@ghostfolio/api/helper/string.helper'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { ApiKeyResponse } from '@ghostfolio/common/interfaces'; + +import { Injectable } from '@nestjs/common'; + +const crypto = require('crypto'); + +@Injectable() +export class ApiKeyService { + public constructor(private readonly prismaService: PrismaService) {} + + public async create({ userId }: { userId: string }): Promise { + let apiKey = getRandomString(32); + apiKey = apiKey + .split('') + .reduce((acc, char, index) => { + const chunkIndex = Math.floor(index / 4); + acc[chunkIndex] = (acc[chunkIndex] || '') + char; + + return acc; + }, []) + .join('-'); + + const iterations = 100000; + const keyLength = 64; + + const hashedKey = crypto + .pbkdf2Sync(apiKey, '', iterations, keyLength, 'sha256') + .toString('hex'); + + await this.prismaService.apiKey.deleteMany({ where: { userId } }); + + await this.prismaService.apiKey.create({ + data: { + hashedKey, + userId + } + }); + + return { apiKey }; + } +} diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts index bde555d8e..f83969d86 100644 --- a/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts @@ -1,3 +1,4 @@ +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 { UserService } from '@ghostfolio/client/services/user/user.service'; @@ -16,7 +17,7 @@ import { MatSnackBarRef, TextOnlySnackBar } from '@angular/material/snack-bar'; -import { StringValue } from 'ms'; +import ms, { StringValue } from 'ms'; import { StripeService } from 'ngx-stripe'; import { EMPTY, Subject } from 'rxjs'; import { catchError, switchMap, takeUntil } from 'rxjs/operators'; @@ -34,6 +35,7 @@ export class UserAccountMembershipComponent implements OnDestroy { public defaultDateFormat: string; public durationExtension: StringValue; public hasPermissionForSubscription: boolean; + public hasPermissionToCreateApiKey: boolean; public hasPermissionToUpdateUserSettings: boolean; public price: number; public priceId: string; @@ -73,6 +75,11 @@ export class UserAccountMembershipComponent implements OnDestroy { this.user.settings.locale ); + this.hasPermissionToCreateApiKey = hasPermission( + this.user.permissions, + permissions.createApiKey + ); + this.hasPermissionToUpdateUserSettings = hasPermission( this.user.permissions, permissions.updateUserSettings @@ -120,6 +127,41 @@ export class UserAccountMembershipComponent implements OnDestroy { }); } + public onGenerateApiKey() { + this.notificationService.confirm({ + confirmFn: () => { + this.dataService + .postApiKey() + .pipe( + takeUntil(this.unsubscribeSubject), + catchError(() => { + this.snackBar.open( + '😞 ' + $localize`Could not generate an API key`, + undefined, + { + duration: ms('3 seconds') + } + ); + + return EMPTY; + }) + ) + .subscribe(({ apiKey }) => { + this.notificationService.alert({ + discardLabel: $localize`Okay`, + message: + $localize`Set this API key in your self-hosted environment:` + + '
' + + apiKey, + title: $localize`Ghostfolio Premium Data Provider API Key` + }); + }); + }, + confirmType: ConfirmationDialogType.Primary, + title: $localize`Do you really want to generate a new API key?` + }); + } + public onRedeemCoupon() { let couponCode = prompt($localize`Please enter your coupon code:`); couponCode = couponCode?.trim(); @@ -134,7 +176,7 @@ export class UserAccountMembershipComponent implements OnDestroy { '😞 ' + $localize`Could not redeem coupon code`, undefined, { - duration: 3000 + duration: ms('3 seconds') } ); diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.html b/apps/client/src/app/components/user-account-membership/user-account-membership.html index 82b329a64..64dd2ce8f 100644 --- a/apps/client/src/app/components/user-account-membership/user-account-membership.html +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.html @@ -4,7 +4,9 @@
@if (user?.subscription?.type === 'Basic') {
diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index dccbb064a..92d030827 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -22,6 +22,7 @@ import { AccountBalancesResponse, Accounts, AdminMarketDataDetails, + ApiKeyResponse, AssetProfileIdentifier, BenchmarkMarketDataDetails, BenchmarkResponse, @@ -289,7 +290,7 @@ export class DataService { public deleteActivities({ filters }) { const params = this.buildFiltersAsQueryParams({ filters }); - return this.http.delete(`/api/v1/order`, { params }); + return this.http.delete('/api/v1/order', { params }); } public deleteActivity(aId: string) { @@ -636,36 +637,40 @@ export class DataService { } public loginAnonymous(accessToken: string) { - return this.http.post(`/api/v1/auth/anonymous`, { + return this.http.post('/api/v1/auth/anonymous', { accessToken }); } public postAccess(aAccess: CreateAccessDto) { - return this.http.post(`/api/v1/access`, aAccess); + return this.http.post('/api/v1/access', aAccess); } public postAccount(aAccount: CreateAccountDto) { - return this.http.post(`/api/v1/account`, aAccount); + return this.http.post('/api/v1/account', aAccount); } public postAccountBalance(aAccountBalance: CreateAccountBalanceDto) { return this.http.post( - `/api/v1/account-balance`, + '/api/v1/account-balance', aAccountBalance ); } + public postApiKey() { + return this.http.post('/api/v1/api-keys', {}); + } + public postBenchmark(benchmark: AssetProfileIdentifier) { - return this.http.post(`/api/v1/benchmark`, benchmark); + return this.http.post('/api/v1/benchmark', benchmark); } public postOrder(aOrder: CreateOrderDto) { - return this.http.post(`/api/v1/order`, aOrder); + return this.http.post('/api/v1/order', aOrder); } public postUser() { - return this.http.post(`/api/v1/user`, {}); + return this.http.post('/api/v1/user', {}); } public putAccount(aAccount: UpdateAccountDto) { @@ -692,7 +697,7 @@ export class DataService { } public putUserSetting(aData: UpdateUserSettingDto) { - return this.http.put(`/api/v1/user/setting`, aData); + return this.http.put('/api/v1/user/setting', aData); } public redeemCoupon(couponCode: string) { diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 4d5ce66d0..344a1f965 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -39,6 +39,7 @@ import type { PortfolioSummary } from './portfolio-summary.interface'; import type { Position } from './position.interface'; import type { Product } from './product'; import type { AccountBalancesResponse } from './responses/account-balances-response.interface'; +import type { ApiKeyResponse } from './responses/api-key-response.interface'; import type { BenchmarkResponse } from './responses/benchmark-response.interface'; import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-response.interface'; import type { DividendsResponse } from './responses/dividends-response.interface'; @@ -72,6 +73,7 @@ export { AdminMarketDataDetails, AdminMarketDataItem, AdminUsers, + ApiKeyResponse, AssetProfileIdentifier, Benchmark, BenchmarkMarketDataDetails, diff --git a/libs/common/src/lib/interfaces/responses/api-key-response.interface.ts b/libs/common/src/lib/interfaces/responses/api-key-response.interface.ts new file mode 100644 index 000000000..dace14a02 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/api-key-response.interface.ts @@ -0,0 +1,3 @@ +export interface ApiKeyResponse { + apiKey: string; +} diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 1a81938b5..cfee1c9e8 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -9,6 +9,7 @@ export const permissions = { createAccess: 'createAccess', createAccount: 'createAccount', createAccountBalance: 'createAccountBalance', + createApiKey: 'createApiKey', createOrder: 'createOrder', createPlatform: 'createPlatform', createTag: 'createTag', diff --git a/libs/ui/src/lib/membership-card/membership-card.component.html b/libs/ui/src/lib/membership-card/membership-card.component.html index 02a4a03f7..1c68f5e3f 100644 --- a/libs/ui/src/lib/membership-card/membership-card.component.html +++ b/libs/ui/src/lib/membership-card/membership-card.component.html @@ -13,6 +13,25 @@ [showLabel]="false" />
+ @if (hasPermissionToCreateApiKey) { +
+
API Key
+
+
* * * * * * * * *
+
+ +
+
+
+ }
Membership
diff --git a/libs/ui/src/lib/membership-card/membership-card.component.scss b/libs/ui/src/lib/membership-card/membership-card.component.scss index a7cbce91a..270adc0f1 100644 --- a/libs/ui/src/lib/membership-card/membership-card.component.scss +++ b/libs/ui/src/lib/membership-card/membership-card.component.scss @@ -42,6 +42,12 @@ background-color: #1d2124; border-radius: calc(var(--borderRadius) - var(--borderWidth)); color: rgba(var(--light-primary-text)); + line-height: 1.2; + + button { + color: rgba(var(--light-primary-text)); + height: 1.5rem; + } .heading { font-size: 13px; diff --git a/libs/ui/src/lib/membership-card/membership-card.component.ts b/libs/ui/src/lib/membership-card/membership-card.component.ts index b19072946..5d05d6fe5 100644 --- a/libs/ui/src/lib/membership-card/membership-card.component.ts +++ b/libs/ui/src/lib/membership-card/membership-card.component.ts @@ -3,15 +3,18 @@ import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, Component, - Input + EventEmitter, + Input, + Output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; import { GfLogoComponent } from '../logo'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, GfLogoComponent, RouterModule], + imports: [CommonModule, GfLogoComponent, MatButtonModule, RouterModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-membership-card', standalone: true, @@ -20,7 +23,17 @@ import { GfLogoComponent } from '../logo'; }) export class GfMembershipCardComponent { @Input() public expiresAt: string; + @Input() public hasPermissionToCreateApiKey: boolean; @Input() public name: string; + @Output() generateApiKeyClicked = new EventEmitter(); + public routerLinkPricing = ['/' + $localize`:snake-case:pricing`]; + + public onGenerateApiKey(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + this.generateApiKeyClicked.emit(); + } }