mirror of https://github.com/ghostfolio/ghostfolio
47 changed files with 938 additions and 303 deletions
@ -0,0 +1,76 @@ |
|||||
|
import { UserService } from '@ghostfolio/api/app/user/user.service'; |
||||
|
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config'; |
||||
|
import { hasRole } from '@ghostfolio/common/permissions'; |
||||
|
|
||||
|
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' |
||||
|
) { |
||||
|
public constructor( |
||||
|
private readonly apiKeyService: ApiKeyService, |
||||
|
private readonly configurationService: ConfigurationService, |
||||
|
private readonly prismaService: PrismaService, |
||||
|
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); |
||||
|
|
||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { |
||||
|
if (hasRole(user, 'INACTIVE')) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
||||
|
StatusCodes.TOO_MANY_REQUESTS |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
await this.prismaService.analytics.upsert({ |
||||
|
create: { User: { connect: { id: user.id } } }, |
||||
|
update: { |
||||
|
activityCount: { increment: 1 }, |
||||
|
lastRequestAt: new Date() |
||||
|
}, |
||||
|
where: { userId: user.id } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
@ -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<ApiKeyResponse> { |
||||
|
return this.apiKeyService.create({ userId: this.request.user.id }); |
||||
|
} |
||||
|
} |
@ -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 {} |
@ -0,0 +1,14 @@ |
|||||
|
import { randomBytes } from 'crypto'; |
||||
|
|
||||
|
export function getRandomString(length: number) { |
||||
|
const bytes = 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(''); |
||||
|
} |
@ -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 {} |
@ -0,0 +1,63 @@ |
|||||
|
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'; |
||||
|
import { pbkdf2Sync } from 'crypto'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class ApiKeyService { |
||||
|
private readonly algorithm = 'sha256'; |
||||
|
private readonly iterations = 100000; |
||||
|
private readonly keyLength = 64; |
||||
|
|
||||
|
public constructor(private readonly prismaService: PrismaService) {} |
||||
|
|
||||
|
public async create({ userId }: { userId: string }): Promise<ApiKeyResponse> { |
||||
|
const apiKey = this.generateApiKey(); |
||||
|
const hashedKey = this.hashApiKey(apiKey); |
||||
|
|
||||
|
await this.prismaService.apiKey.deleteMany({ where: { userId } }); |
||||
|
|
||||
|
await this.prismaService.apiKey.create({ |
||||
|
data: { |
||||
|
hashedKey, |
||||
|
userId |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return { apiKey }; |
||||
|
} |
||||
|
|
||||
|
public async getUserByApiKey(apiKey: string) { |
||||
|
const hashedKey = this.hashApiKey(apiKey); |
||||
|
|
||||
|
const { user } = await this.prismaService.apiKey.findUnique({ |
||||
|
include: { user: true }, |
||||
|
where: { hashedKey } |
||||
|
}); |
||||
|
|
||||
|
return user; |
||||
|
} |
||||
|
|
||||
|
public hashApiKey(apiKey: string): string { |
||||
|
return 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('-'); |
||||
|
} |
||||
|
} |
@ -1,15 +0,0 @@ |
|||||
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; |
|
||||
|
|
||||
import { CommonModule } from '@angular/common'; |
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
|
||||
|
|
||||
import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component'; |
|
||||
import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module'; |
|
||||
|
|
||||
@NgModule({ |
|
||||
declarations: [AdminMarketDataDetailComponent], |
|
||||
exports: [AdminMarketDataDetailComponent], |
|
||||
imports: [CommonModule, GfLineChartComponent, GfMarketDataDetailDialogModule], |
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
|
||||
}) |
|
||||
export class GfAdminMarketDataDetailModule {} |
|
@ -1,26 +0,0 @@ |
|||||
import { CommonModule } from '@angular/common'; |
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; |
|
||||
import { MatButtonModule } from '@angular/material/button'; |
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker'; |
|
||||
import { MatDialogModule } from '@angular/material/dialog'; |
|
||||
import { MatFormFieldModule } from '@angular/material/form-field'; |
|
||||
import { MatInputModule } from '@angular/material/input'; |
|
||||
|
|
||||
import { MarketDataDetailDialog } from './market-data-detail-dialog.component'; |
|
||||
|
|
||||
@NgModule({ |
|
||||
declarations: [MarketDataDetailDialog], |
|
||||
imports: [ |
|
||||
CommonModule, |
|
||||
FormsModule, |
|
||||
MatButtonModule, |
|
||||
MatDatepickerModule, |
|
||||
MatDialogModule, |
|
||||
MatFormFieldModule, |
|
||||
MatInputModule, |
|
||||
ReactiveFormsModule |
|
||||
], |
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
|
||||
}) |
|
||||
export class GfMarketDataDetailDialogModule {} |
|
@ -0,0 +1,3 @@ |
|||||
|
export interface ApiKeyResponse { |
||||
|
apiKey: string; |
||||
|
} |
@ -1,34 +1,58 @@ |
|||||
import { AdminService } from '@ghostfolio/client/services/admin.service'; |
import { AdminService } from '@ghostfolio/client/services/admin.service'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
import { |
import { |
||||
ChangeDetectionStrategy, |
ChangeDetectionStrategy, |
||||
ChangeDetectorRef, |
ChangeDetectorRef, |
||||
Component, |
Component, |
||||
|
CUSTOM_ELEMENTS_SCHEMA, |
||||
Inject, |
Inject, |
||||
OnDestroy |
OnDestroy |
||||
} from '@angular/core'; |
} from '@angular/core'; |
||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core'; |
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core'; |
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; |
import { MatDatepickerModule } from '@angular/material/datepicker'; |
||||
|
import { |
||||
|
MAT_DIALOG_DATA, |
||||
|
MatDialogModule, |
||||
|
MatDialogRef |
||||
|
} from '@angular/material/dialog'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatInputModule } from '@angular/material/input'; |
||||
import { Subject, takeUntil } from 'rxjs'; |
import { Subject, takeUntil } from 'rxjs'; |
||||
|
|
||||
import { MarketDataDetailDialogParams } from './interfaces/interfaces'; |
import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces'; |
||||
|
|
||||
@Component({ |
@Component({ |
||||
host: { class: 'h-100' }, |
|
||||
selector: 'gf-market-data-detail-dialog', |
|
||||
changeDetection: ChangeDetectionStrategy.OnPush, |
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
styleUrls: ['./market-data-detail-dialog.scss'], |
host: { class: 'h-100' }, |
||||
templateUrl: 'market-data-detail-dialog.html' |
imports: [ |
||||
|
CommonModule, |
||||
|
FormsModule, |
||||
|
MatButtonModule, |
||||
|
MatDatepickerModule, |
||||
|
MatDialogModule, |
||||
|
MatFormFieldModule, |
||||
|
MatInputModule, |
||||
|
ReactiveFormsModule |
||||
|
], |
||||
|
selector: 'gf-historical-market-data-editor-dialog', |
||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA], |
||||
|
standalone: true, |
||||
|
styleUrls: ['./historical-market-data-editor-dialog.scss'], |
||||
|
templateUrl: 'historical-market-data-editor-dialog.html' |
||||
}) |
}) |
||||
export class MarketDataDetailDialog implements OnDestroy { |
export class GfHistoricalMarketDataEditorDialogComponent implements OnDestroy { |
||||
private unsubscribeSubject = new Subject<void>(); |
private unsubscribeSubject = new Subject<void>(); |
||||
|
|
||||
public constructor( |
public constructor( |
||||
private adminService: AdminService, |
private adminService: AdminService, |
||||
private changeDetectorRef: ChangeDetectorRef, |
private changeDetectorRef: ChangeDetectorRef, |
||||
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams, |
@Inject(MAT_DIALOG_DATA) |
||||
|
public data: HistoricalMarketDataEditorDialogParams, |
||||
private dateAdapter: DateAdapter<any>, |
private dateAdapter: DateAdapter<any>, |
||||
public dialogRef: MatDialogRef<MarketDataDetailDialog>, |
public dialogRef: MatDialogRef<GfHistoricalMarketDataEditorDialogComponent>, |
||||
@Inject(MAT_DATE_LOCALE) private locale: string |
@Inject(MAT_DATE_LOCALE) private locale: string |
||||
) {} |
) {} |
||||
|
|
@ -0,0 +1 @@ |
|||||
|
export * from './historical-market-data-editor.component'; |
@ -0,0 +1,5 @@ |
|||||
|
-- DropIndex |
||||
|
DROP INDEX "ApiKey_hashedKey_idx"; |
||||
|
|
||||
|
-- CreateIndex |
||||
|
CREATE UNIQUE INDEX "ApiKey_hashedKey_key" ON "ApiKey"("hashedKey"); |
Loading…
Reference in new issue