Browse Source

Setup API key strategy

pull/4093/head
Thomas Kaul 9 months ago
parent
commit
95e5185a92
  1. 55
      apps/api/src/app/auth/api-key.strategy.ts
  2. 4
      apps/api/src/app/auth/auth.module.ts
  3. 18
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  4. 3
      apps/api/src/app/user/user.service.ts
  5. 56
      apps/api/src/services/api-key/api-key.service.ts
  6. 8
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  7. 40
      apps/client/src/app/pages/api/api-page.component.ts
  8. 16
      apps/client/src/app/services/admin.service.ts
  9. 11
      package-lock.json
  10. 1
      package.json

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

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

18
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<DataProviderGhostfolioStatusResponse> {
return this.ghostfolioService.getStatus({ user: this.request.user });
}
@Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> {
return this.ghostfolioService.getStatus({ user: this.request.user });
}

3
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);

56
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<ApiKeyResponse> {
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<ApiKeyResponse> {
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('-');
}
}

8
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

40
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<DataProviderGhostfolioStatusResponse>;
public symbols$: Observable<LookupResponse['items']>;
private apiKey: string;
private unsubscribeSubject = new Subject<void>();
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<DividendsResponse>(
`/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<HistoricalResponse>(
`/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<QuotesResponse>('/api/v1/data-providers/ghostfolio/quotes', {
params
.get<QuotesResponse>('/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<DataProviderGhostfolioStatusResponse>(
'/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<LookupResponse>('/api/v1/data-providers/ghostfolio/lookup', {
.get<LookupResponse>('/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}`
});
}
}

16
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<DataProviderGhostfolioStatusResponse>(
`${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 }
);
})
);

11
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",

1
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",

Loading…
Cancel
Save