From 95e5185a92be22ce83d25b3e37ff55702289990a Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 6 Dec 2024 20:45:28 +0100 Subject: [PATCH] Setup API key strategy --- apps/api/src/app/auth/api-key.strategy.ts | 55 ++++++++++++++++++ apps/api/src/app/auth/auth.module.ts | 4 ++ .../ghostfolio/ghostfolio.controller.ts | 18 +++++- apps/api/src/app/user/user.service.ts | 3 +- .../src/services/api-key/api-key.service.ts | 56 ++++++++++++------- .../ghostfolio/ghostfolio.service.ts | 8 +-- .../src/app/pages/api/api-page.component.ts | 40 ++++++++++--- apps/client/src/app/services/admin.service.ts | 16 +++--- package-lock.json | 11 ++++ package.json | 1 + 10 files changed, 168 insertions(+), 44 deletions(-) create mode 100644 apps/api/src/app/auth/api-key.strategy.ts diff --git a/apps/api/src/app/auth/api-key.strategy.ts b/apps/api/src/app/auth/api-key.strategy.ts new file mode 100644 index 000000000..0582326c9 --- /dev/null +++ b/apps/api/src/app/auth/api-key.strategy.ts @@ -0,0 +1,55 @@ +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; +import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config'; + +import { HttpException, Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; + +@Injectable() +export class ApiKeyStrategy extends PassportStrategy( + HeaderAPIKeyStrategy, + 'api-key' +) { + constructor( + private readonly apiKeyService: ApiKeyService, + private readonly userService: UserService + ) { + super( + { header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, + true, + async (apiKey: string, done: (error: any, user?: any) => void) => { + try { + const user = await this.validateApiKey(apiKey); + + // TODO: Add checks from JwtStrategy + + done(null, user); + } catch (error) { + done(error, null); + } + } + ); + } + + private async validateApiKey(apiKey: string) { + if (!apiKey) { + throw new HttpException( + getReasonPhrase(StatusCodes.UNAUTHORIZED), + StatusCodes.UNAUTHORIZED + ); + } + + try { + const { id } = await this.apiKeyService.getUserByApiKey(apiKey); + + return this.userService.user({ id }); + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.UNAUTHORIZED), + StatusCodes.UNAUTHORIZED + ); + } + } +} diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 67b078c9b..824c432b1 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -2,6 +2,7 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; @@ -9,6 +10,7 @@ import { PropertyModule } from '@ghostfolio/api/services/property/property.modul import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; +import { ApiKeyStrategy } from './api-key.strategy'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { GoogleStrategy } from './google.strategy'; @@ -28,6 +30,8 @@ import { JwtStrategy } from './jwt.strategy'; UserModule ], providers: [ + ApiKeyService, + ApiKeyStrategy, AuthDeviceService, AuthService, GoogleStrategy, diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts index 788cfd1bc..c2e1a7f4c 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts @@ -18,7 +18,8 @@ import { Inject, Param, Query, - UseGuards + UseGuards, + Version } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; @@ -36,6 +37,7 @@ export class GhostfolioController { @Inject(REQUEST) private readonly request: RequestWithUser ) {} + // TODO: v2 @Get('dividends/:symbol') @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -75,6 +77,7 @@ export class GhostfolioController { } } + // TODO: v2 @Get('historical/:symbol') @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -114,6 +117,7 @@ export class GhostfolioController { } } + // TODO: v2 @Get('lookup') @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -152,6 +156,7 @@ export class GhostfolioController { } } + // TODO: v2 @Get('quotes') @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -187,9 +192,20 @@ export class GhostfolioController { } } + /** + * @deprecated + */ @Get('status') @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getStatusV1(): Promise { + return this.ghostfolioService.getStatus({ user: this.request.user }); + } + + @Get('status') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('api-key'), HasPermissionGuard) + @Version('2') public async getStatus(): Promise { return this.ghostfolioService.getStatus({ user: this.request.user }); } diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 611a28721..9d51c6d2f 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -310,8 +310,7 @@ 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.createApiKey); currentPermissions.push(permissions.enableDataProviderGhostfolio); currentPermissions.push(permissions.reportDataGlitch); diff --git a/apps/api/src/services/api-key/api-key.service.ts b/apps/api/src/services/api-key/api-key.service.ts index c74a90de9..6a80665f7 100644 --- a/apps/api/src/services/api-key/api-key.service.ts +++ b/apps/api/src/services/api-key/api-key.service.ts @@ -3,31 +3,19 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { ApiKeyResponse } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; - -const crypto = require('crypto'); +import * as crypto from 'crypto'; @Injectable() export class ApiKeyService { - public constructor(private readonly prismaService: PrismaService) {} + private readonly algorithm = 'sha256'; + private readonly iterations = 100000; + private readonly keyLength = 64; - 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; + constructor(private readonly prismaService: PrismaService) {} - return acc; - }, []) - .join('-'); - - const iterations = 100000; - const keyLength = 64; - - const hashedKey = crypto - .pbkdf2Sync(apiKey, '', iterations, keyLength, 'sha256') - .toString('hex'); + public async create({ userId }: { userId: string }): Promise { + const apiKey = this.generateApiKey(); + const hashedKey = this.hashApiKey(apiKey); await this.prismaService.apiKey.deleteMany({ where: { userId } }); @@ -40,4 +28,32 @@ export class ApiKeyService { return { apiKey }; } + + public async getUserByApiKey(apiKey: string) { + const hashedKey = this.hashApiKey(apiKey); + + const { user } = await this.prismaService.apiKey.findFirst({ + include: { user: true }, + where: { hashedKey } + }); + + return user; + } + + public hashApiKey(apiKey: string): string { + return crypto + .pbkdf2Sync(apiKey, '', this.iterations, this.keyLength, this.algorithm) + .toString('hex'); + } + + private generateApiKey(): string { + return getRandomString(32) + .split('') + .reduce((acc, char, index) => { + const chunkIndex = Math.floor(index / 4); + acc[chunkIndex] = (acc[chunkIndex] || '') + char; + return acc; + }, []) + .join('-'); + } } diff --git a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts index 25ffdc677..3c31c1ba2 100644 --- a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts @@ -93,7 +93,7 @@ export class GhostfolioService implements DataProviderInterface { }, requestTimeout); const { dividends } = await got( - `${this.URL}/v1/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( + `${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( to, DATE_FORMAT )}`, @@ -138,7 +138,7 @@ export class GhostfolioService implements DataProviderInterface { }, requestTimeout); const { historicalData } = await got( - `${this.URL}/v1/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( + `${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( to, DATE_FORMAT )}`, @@ -201,7 +201,7 @@ export class GhostfolioService implements DataProviderInterface { }, requestTimeout); const { quotes } = await got( - `${this.URL}/v1/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`, + `${this.URL}/v2/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`, { headers: await this.getRequestHeaders(), // @ts-ignore @@ -245,7 +245,7 @@ export class GhostfolioService implements DataProviderInterface { }, this.configurationService.get('REQUEST_TIMEOUT')); searchResult = await got( - `${this.URL}/v1/data-providers/ghostfolio/lookup?query=${query}`, + `${this.URL}/v2/data-providers/ghostfolio/lookup?query=${query}`, { headers: await this.getRequestHeaders(), // @ts-ignore diff --git a/apps/client/src/app/pages/api/api-page.component.ts b/apps/client/src/app/pages/api/api-page.component.ts index aa176c0f0..c83d80ceb 100644 --- a/apps/client/src/app/pages/api/api-page.component.ts +++ b/apps/client/src/app/pages/api/api-page.component.ts @@ -1,3 +1,7 @@ +import { + HEADER_KEY_SKIP_INTERCEPTOR, + HEADER_KEY_TOKEN +} from '@ghostfolio/common/config'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DataProviderGhostfolioStatusResponse, @@ -8,7 +12,7 @@ import { } from '@ghostfolio/common/interfaces'; import { CommonModule } from '@angular/common'; -import { HttpClient, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { Component, OnInit } from '@angular/core'; import { format, startOfYear } from 'date-fns'; import { map, Observable, Subject, takeUntil } from 'rxjs'; @@ -28,11 +32,14 @@ export class GfApiPageComponent implements OnInit { public status$: Observable; public symbols$: Observable; + private apiKey: string; private unsubscribeSubject = new Subject(); public constructor(private http: HttpClient) {} public ngOnInit() { + this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`); + this.dividends$ = this.fetchDividends({ symbol: 'KO' }); this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL.US' }); this.quotes$ = this.fetchQuotes({ symbols: ['AAPL.US', 'VOO.US'] }); @@ -52,8 +59,11 @@ export class GfApiPageComponent implements OnInit { return this.http .get( - `/api/v1/data-providers/ghostfolio/dividends/${symbol}`, - { params } + `/api/v2/data-providers/ghostfolio/dividends/${symbol}`, + { + params, + headers: this.getHeaders() + } ) .pipe( map(({ dividends }) => { @@ -70,8 +80,11 @@ export class GfApiPageComponent implements OnInit { return this.http .get( - `/api/v1/data-providers/ghostfolio/historical/${symbol}`, - { params } + `/api/v2/data-providers/ghostfolio/historical/${symbol}`, + { + params, + headers: this.getHeaders() + } ) .pipe( map(({ historicalData }) => { @@ -85,8 +98,9 @@ export class GfApiPageComponent implements OnInit { const params = new HttpParams().set('symbols', symbols.join(',')); return this.http - .get('/api/v1/data-providers/ghostfolio/quotes', { - params + .get('/api/v2/data-providers/ghostfolio/quotes', { + params, + headers: this.getHeaders() }) .pipe( map(({ quotes }) => { @@ -99,7 +113,8 @@ export class GfApiPageComponent implements OnInit { private fetchStatus() { return this.http .get( - '/api/v1/data-providers/ghostfolio/status' + '/api/v2/data-providers/ghostfolio/status', + { headers: this.getHeaders() } ) .pipe(takeUntil(this.unsubscribeSubject)); } @@ -118,7 +133,7 @@ export class GfApiPageComponent implements OnInit { } return this.http - .get('/api/v1/data-providers/ghostfolio/lookup', { + .get('/api/v2/data-providers/ghostfolio/lookup', { params }) .pipe( @@ -128,4 +143,11 @@ export class GfApiPageComponent implements OnInit { takeUntil(this.unsubscribeSubject) ); } + + private getHeaders() { + return new HttpHeaders({ + [HEADER_KEY_SKIP_INTERCEPTOR]: 'true', + [HEADER_KEY_TOKEN]: `Api-Key ${this.apiKey}` + }); + } } diff --git a/apps/client/src/app/services/admin.service.ts b/apps/client/src/app/services/admin.service.ts index 5d252f00f..3978331c8 100644 --- a/apps/client/src/app/services/admin.service.ts +++ b/apps/client/src/app/services/admin.service.ts @@ -24,7 +24,7 @@ import { Filter } from '@ghostfolio/common/interfaces'; -import { HttpClient, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { SortDirection } from '@angular/material/sort'; import { DataSource, MarketData, Platform, Tag } from '@prisma/client'; @@ -147,14 +147,14 @@ export class AdminService { public fetchGhostfolioDataProviderStatus() { return this.fetchAdminData().pipe( switchMap(({ settings }) => { + const headers = new HttpHeaders({ + [HEADER_KEY_SKIP_INTERCEPTOR]: 'true', + [HEADER_KEY_TOKEN]: `Bearer ${settings[PROPERTY_API_KEY_GHOSTFOLIO]}` + }); + return this.http.get( - `${environment.production ? 'https://ghostfol.io' : ''}/api/v1/data-providers/ghostfolio/status`, - { - headers: { - [HEADER_KEY_SKIP_INTERCEPTOR]: 'true', - [HEADER_KEY_TOKEN]: `Bearer ${settings[PROPERTY_API_KEY_GHOSTFOLIO]}` - } - } + `${environment.production ? 'https://ghostfol.io' : ''}/api/v2/data-providers/ghostfolio/status`, + { headers } ); }) ); diff --git a/package-lock.json b/package-lock.json index ae7b60a44..0b93a63b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -83,6 +83,7 @@ "papaparse": "5.3.1", "passport": "0.7.0", "passport-google-oauth20": "2.0.0", + "passport-headerapikey": "1.2.2", "passport-jwt": "4.0.1", "reflect-metadata": "0.1.13", "rxjs": "7.5.6", @@ -28414,6 +28415,16 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-headerapikey": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/passport-headerapikey/-/passport-headerapikey-1.2.2.tgz", + "integrity": "sha512-4BvVJRrWsNJPrd3UoZfcnnl4zvUWYKEtfYkoDsaOKBsrWHYmzTApCjs7qUbncOLexE9ul0IRiYBFfBG0y9IVQA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15", + "passport-strategy": "^1.0.0" + } + }, "node_modules/passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", diff --git a/package.json b/package.json index 7f124ea20..24e0d64a4 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "papaparse": "5.3.1", "passport": "0.7.0", "passport-google-oauth20": "2.0.0", + "passport-headerapikey": "1.2.2", "passport-jwt": "4.0.1", "reflect-metadata": "0.1.13", "rxjs": "7.5.6",