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 { CommonModule } from '@angular/common'; |
|||
import { |
|||
ChangeDetectionStrategy, |
|||
ChangeDetectorRef, |
|||
Component, |
|||
CUSTOM_ELEMENTS_SCHEMA, |
|||
Inject, |
|||
OnDestroy |
|||
} 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 { 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 { MarketDataDetailDialogParams } from './interfaces/interfaces'; |
|||
import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces'; |
|||
|
|||
@Component({ |
|||
host: { class: 'h-100' }, |
|||
selector: 'gf-market-data-detail-dialog', |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
styleUrls: ['./market-data-detail-dialog.scss'], |
|||
templateUrl: 'market-data-detail-dialog.html' |
|||
host: { class: 'h-100' }, |
|||
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>(); |
|||
|
|||
public constructor( |
|||
private adminService: AdminService, |
|||
private changeDetectorRef: ChangeDetectorRef, |
|||
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams, |
|||
@Inject(MAT_DIALOG_DATA) |
|||
public data: HistoricalMarketDataEditorDialogParams, |
|||
private dateAdapter: DateAdapter<any>, |
|||
public dialogRef: MatDialogRef<MarketDataDetailDialog>, |
|||
public dialogRef: MatDialogRef<GfHistoricalMarketDataEditorDialogComponent>, |
|||
@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