diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 20f0b3103..c06501f70 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -28,12 +28,14 @@ import { PortfolioModule } from './portfolio/portfolio.module'; import { RedisCacheModule } from './redis-cache/redis-cache.module'; import { SymbolModule } from './symbol/symbol.module'; import { UserModule } from './user/user.module'; +import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module'; @Module({ imports: [ AdminModule, AccessModule, AccountModule, + AuthDeviceModule, AuthModule, CacheModule, ConfigModule.forRoot(), diff --git a/apps/api/src/app/auth-device/auth-device.controller.ts b/apps/api/src/app/auth-device/auth-device.controller.ts new file mode 100644 index 000000000..25a8759c2 --- /dev/null +++ b/apps/api/src/app/auth-device/auth-device.controller.ts @@ -0,0 +1,107 @@ +import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type'; +import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper'; +import { Body, Controller, Delete, Get, HttpException, Inject, Param, Put, UseGuards } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; +import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; + +@Controller('auth-device') +export class AuthDeviceController { + public constructor( + private readonly authDeviceService: AuthDeviceService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) { + } + + @Delete(':id') + @UseGuards(AuthGuard('jwt')) + public async deleteAuthDevice(@Param('id') id: string): Promise { + if ( + !hasPermission( + getPermissions(this.request.user.role), + permissions.deleteAuthDevice + ) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const deletedAuthDevice = await this.authDeviceService.deleteAuthDevice( + { + id_userId: { + id, + userId: this.request.user.id + } + } + ); + return { + id: deletedAuthDevice.id, + createdAt: deletedAuthDevice.createdAt.toISOString(), + name: deletedAuthDevice.name + }; + } + + @Put(':id') + @UseGuards(AuthGuard('jwt')) + public async updateAuthDevice(@Param('id') id: string, @Body() data: AuthDeviceDto) { + if ( + !hasPermission( + getPermissions(this.request.user.role), + permissions.updateAuthDevice + ) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const originalAuthDevice = await this.authDeviceService.authDevice({ + id_userId: { + id, + userId: this.request.user.id + } + }); + + if (!originalAuthDevice) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.authDeviceService.updateAuthDevice( + { + data: { + name: data.name + }, + where: { + id_userId: { + id, + userId: this.request.user.id + } + } + } + ); + } + + @Get() + @UseGuards(AuthGuard('jwt')) + public async getAllAuthDevices(): Promise { + + const authDevices = await this.authDeviceService.authDevices({ + orderBy: { createdAt: 'desc' }, + where: { userId: this.request.user.id } + }); + + return authDevices.map(authDevice => ({ + id: authDevice.id, + createdAt: authDevice.createdAt.toISOString(), + name: authDevice.name + })); + } +} diff --git a/apps/api/src/app/auth-device/auth-device.dto.ts b/apps/api/src/app/auth-device/auth-device.dto.ts new file mode 100644 index 000000000..20819e0e2 --- /dev/null +++ b/apps/api/src/app/auth-device/auth-device.dto.ts @@ -0,0 +1,5 @@ +export interface AuthDeviceDto { + createdAt: string; + id: string; + name: string; +} diff --git a/apps/api/src/app/auth-device/auth-device.module.ts b/apps/api/src/app/auth-device/auth-device.module.ts new file mode 100644 index 000000000..bca5de38d --- /dev/null +++ b/apps/api/src/app/auth-device/auth-device.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller'; +import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; + +@Module({ + controllers: [AuthDeviceController], + imports: [ + JwtModule.register({ + secret: process.env.JWT_SECRET_KEY, + signOptions: { expiresIn: '180 days' } + }) + ], + providers: [ + AuthDeviceService, + PrismaService, + ConfigurationService, + ] +}) +export class AuthDeviceModule {} diff --git a/apps/api/src/app/auth-device/auth-device.service.ts b/apps/api/src/app/auth-device/auth-device.service.ts new file mode 100644 index 000000000..8d087bfb4 --- /dev/null +++ b/apps/api/src/app/auth-device/auth-device.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { AuthDevice, Order, Prisma } from '@prisma/client'; + +@Injectable() +export class AuthDeviceService { + + public constructor( + private readonly configurationService: ConfigurationService, + private prisma: PrismaService + ) { + } + + public async authDevice( + where: Prisma.AuthDeviceWhereUniqueInput + ): Promise { + return this.prisma.authDevice.findUnique({ + where + }); + } + + public async authDevices(params: { + skip?: number; + take?: number; + cursor?: Prisma.AuthDeviceWhereUniqueInput; + where?: Prisma.AuthDeviceWhereInput; + orderBy?: Prisma.AuthDeviceOrderByInput; + }): Promise { + const { skip, take, cursor, where, orderBy } = params; + return this.prisma.authDevice.findMany({ + skip, + take, + cursor, + where, + orderBy + }); + } + + public async createAuthDevice( + data: Prisma.AuthDeviceCreateInput + ): Promise { + + return this.prisma.authDevice.create({ + data + }); + } + + public async updateAuthDevice( + params: { + where: Prisma.AuthDeviceWhereUniqueInput; + data: Prisma.AuthDeviceUpdateInput; + }, + ): Promise { + const { data, where } = params; + + return this.prisma.authDevice.update({ + data, + where + }); + } + + public async deleteAuthDevice( + where: Prisma.AuthDeviceWhereUniqueInput, + ): Promise { + return this.prisma.authDevice.delete({ + where + }); + } +} diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index cfafa080e..f74b3c77c 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -1,9 +1,10 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { + Body, Controller, Get, HttpException, - Param, + Param, Post, Req, Res, UseGuards @@ -12,11 +13,15 @@ import { AuthGuard } from '@nestjs/passport'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AuthService } from './auth.service'; +import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; +// TODO fix type compilation error +// import { AttestationCredentialJSON } from '@simplewebauthn/typescript-types'; @Controller('auth') export class AuthController { public constructor( private readonly authService: AuthService, + private readonly webAuthService: WebAuthService, private readonly configurationService: ConfigurationService ) {} @@ -53,4 +58,28 @@ export class AuthController { res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`); } } + + @Get('webauthn/generate-attestation-options') + @UseGuards(AuthGuard('jwt')) + public async generateAttestationOptions() { + return this.webAuthService.generateAttestationOptions(); + } + + @Post('webauthn/verify-attestation') + @UseGuards(AuthGuard('jwt')) + public async verifyAttestation(@Body() body: any) { + return this.webAuthService.verifyAttestation(body); + } + + @Get('webauthn/generate-assertion-options') + @UseGuards(AuthGuard('jwt')) + public async generateAssertionOptions() { + return this.webAuthService.generateAssertionOptions(); + } + + @Post('webauthn/verify-assertion') + @UseGuards(AuthGuard('jwt')) + public async verifyAssertion(@Body() body: any) { + return this.webAuthService.verifyAssertion(body); + } } diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 0519e2509..ccea5a687 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -8,6 +8,8 @@ import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { GoogleStrategy } from './google.strategy'; import { JwtStrategy } from './jwt.strategy'; +import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; @Module({ controllers: [AuthController], @@ -23,7 +25,9 @@ import { JwtStrategy } from './jwt.strategy'; GoogleStrategy, JwtStrategy, PrismaService, - UserService + UserService, + WebAuthService, + AuthDeviceService, ] }) export class AuthModule {} diff --git a/apps/api/src/app/auth/web-auth.service.ts b/apps/api/src/app/auth/web-auth.service.ts new file mode 100644 index 000000000..ce05ab997 --- /dev/null +++ b/apps/api/src/app/auth/web-auth.service.ts @@ -0,0 +1,221 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; + +import { UserService } from '../user/user.service'; +import { + generateAssertionOptions, + GenerateAssertionOptionsOpts, + generateAttestationOptions, + GenerateAttestationOptionsOpts, + VerifiedAssertion, + VerifiedAttestation, + verifyAssertionResponse, + VerifyAssertionResponseOpts, + verifyAttestationResponse, + VerifyAttestationResponseOpts +} from '@simplewebauthn/server'; +import { REQUEST } from '@nestjs/core'; +import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type'; +// TODO fix type compilation error +// import { AttestationCredentialJSON } from '@simplewebauthn/typescript-types'; +import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import base64url from 'base64url'; + +@Injectable() +export class WebAuthService { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly userService: UserService, + private readonly deviceService: AuthDeviceService, + @Inject(REQUEST) private readonly request: RequestWithUser, + ) {} + + get rpName() { + return this.configurationService.get('WEB_AUTH_RP_NAME'); + } + + get rpID() { + return this.configurationService.get('WEB_AUTH_RP_ID'); + } + + get expectedOrigin() { + return this.configurationService.get('ROOT_URL'); + } + + public async generateAttestationOptions() { + const user = this.request.user; + const devices = await this.deviceService.authDevices({where: {userId: user.id}}); + + const opts: GenerateAttestationOptionsOpts = { + rpName: this.rpName, + rpID: this.rpID, + userID: user.id, + userName: user.alias, + timeout: 60000, + attestationType: 'indirect', + /** + * Passing in a user's list of already-registered authenticator IDs here prevents users from + * registering the same device multiple times. The authenticator will simply throw an error in + * the browser if it's asked to perform an attestation when one of these ID's already resides + * on it. + */ + excludeCredentials: devices.map(device => ({ + id: device.credentialId, + type: 'public-key', + transports: ['usb', 'ble', 'nfc', 'internal'], + })), + /** + * The optional authenticatorSelection property allows for specifying more constraints around + * the types of authenticators that users to can use for attestation + */ + authenticatorSelection: { + userVerification: 'preferred', + requireResidentKey: false, + }, + }; + + const options = generateAttestationOptions(opts); + + /** + * The server needs to temporarily remember this value for verification, so don't lose it until + * after you verify an authenticator response. + */ + await this.userService.updateUser({ + data: { + authChallenge: options.challenge, + }, + where: { + id: user.id, + } + }) + + return options; + } + + public async verifyAttestation(body: any){ + + const user = this.request.user; + const expectedChallenge = user.authChallenge; + + let verification: VerifiedAttestation; + try { + const opts: VerifyAttestationResponseOpts = { + credential: body, + expectedChallenge, + expectedOrigin: this.expectedOrigin, + expectedRPID: this.rpID, + }; + verification = await verifyAttestationResponse(opts); + } catch (error) { + console.error(error); + return new InternalServerErrorException(error.message); + } + + const { verified, attestationInfo } = verification; + + const devices = await this.deviceService.authDevices({where: {userId: user.id}}); + if (verified && attestationInfo) { + const { credentialPublicKey, credentialID, counter } = attestationInfo; + + const existingDevice = devices.find(device => device.credentialId === credentialID); + + if (!existingDevice) { + /** + * Add the returned device to the user's list of devices + */ + await this.deviceService.createAuthDevice({ + credentialPublicKey, + credentialId: credentialID, + counter, + name: body.deviceName, + User: { connect: { id: user.id } } + }) + } + } + + return { verified }; + } + + public async generateAssertionOptions(){ + const user = this.request.user; + const devices = await this.deviceService.authDevices({where: {userId: user.id}}); + + const opts: GenerateAssertionOptionsOpts = { + timeout: 60000, + allowCredentials: devices.map(dev => ({ + id: dev.credentialId, + type: 'public-key', + transports: ['usb', 'ble', 'nfc', 'internal'], + })), + /** + * This optional value controls whether or not the authenticator needs be able to uniquely + * identify the user interacting with it (via built-in PIN pad, fingerprint scanner, etc...) + */ + userVerification: 'preferred', + rpID: this.rpID, + }; + + const options = generateAssertionOptions(opts); + + /** + * The server needs to temporarily remember this value for verification, so don't lose it until + * after you verify an authenticator response. + */ + await this.userService.updateUser({ + data: { + authChallenge: options.challenge, + }, + where: { + id: user.id, + } + }) + + return options; + } + + public async verifyAssertion(body: any){ + + const user = this.request.user; + + const bodyCredIDBuffer = base64url.toBuffer(body.rawId); + const devices = await this.deviceService.authDevices({where: {credentialId: bodyCredIDBuffer}}); + + if (devices.length !== 1) { + throw new InternalServerErrorException(`Could not find authenticator matching ${body.id}`); + } + const authenticator = devices[0]; + + let verification: VerifiedAssertion; + try { + const opts: VerifyAssertionResponseOpts = { + credential: body, + expectedChallenge: `${user.authChallenge}`, + expectedOrigin: this.expectedOrigin, + expectedRPID: this.rpID, + authenticator: { + credentialID: authenticator.credentialId, + credentialPublicKey: authenticator.credentialPublicKey, + counter: authenticator.counter, + }, + }; + verification = verifyAssertionResponse(opts); + } catch (error) { + console.error(error); + throw new InternalServerErrorException({ error: error.message }); + } + + const { verified, assertionInfo } = verification; + + if (verified) { + // Update the authenticator's counter in the DB to the newest count in the assertion + authenticator.counter = assertionInfo.newCounter; + + await this.deviceService.updateAuthDevice({ + data: authenticator, + where: {id_userId: { id: authenticator.id, userId: user.id}} + }) + } + + return { verified }; + } +} diff --git a/apps/api/src/services/configuration.service.ts b/apps/api/src/services/configuration.service.ts index 0ba57989c..27f6be8e5 100644 --- a/apps/api/src/services/configuration.service.ts +++ b/apps/api/src/services/configuration.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; -import { bool, cleanEnv, json, num, port, str } from 'envalid'; +import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; import { Environment } from './interfaces/environment.interface'; @@ -26,7 +26,9 @@ export class ConfigurationService { RAKUTEN_RAPID_API_KEY: str({ default: '' }), REDIS_HOST: str({ default: 'localhost' }), REDIS_PORT: port({ default: 6379 }), - ROOT_URL: str({ default: 'http://localhost:4200' }) + ROOT_URL: str({ default: 'http://localhost:4200' }), + WEB_AUTH_RP_ID: host({ default: 'localhost' }), + WEB_AUTH_RP_NAME: str({ default: 'Ghostfolio' }), }); } diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 89ff79d33..bbd2fec28 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -18,4 +18,6 @@ export interface Environment extends CleanedEnvAccessors { REDIS_HOST: string; REDIS_PORT: number; ROOT_URL: string; + WEB_AUTH_RP_ID: string; + WEB_AUTH_RP_NAME: string; } diff --git a/apps/client/src/app/app-routing.module.ts b/apps/client/src/app/app-routing.module.ts index 51dcaf4f5..2c741d740 100644 --- a/apps/client/src/app/app-routing.module.ts +++ b/apps/client/src/app/app-routing.module.ts @@ -33,6 +33,13 @@ const routes: Routes = [ loadChildren: () => import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule) }, + { + path: 'analysis', + loadChildren: () => + import('./pages/analysis/analysis-page.module').then( + (m) => m.AnalysisPageModule + ) + }, { path: 'home', loadChildren: () => diff --git a/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.css b/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html b/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html new file mode 100644 index 000000000..66a5d4550 --- /dev/null +++ b/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + +
Name{{element.name}}Created at{{element.createdAt | date}} + + + + + +
+ + + + diff --git a/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.ts b/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.ts new file mode 100644 index 000000000..c7ce02dd3 --- /dev/null +++ b/apps/client/src/app/components/auth-device-settings/auth-device-settings.component.ts @@ -0,0 +1,58 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core'; +import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; +import { MatTableDataSource } from '@angular/material/table'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; + +@Component({ + selector: 'gf-auth-device-settings', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './auth-device-settings.component.html', + styleUrls: ['./auth-device-settings.component.css'] +}) +export class AuthDeviceSettingsComponent implements OnInit, OnChanges { + + @Input() authDevices: AuthDeviceDto[]; + + @Output() authDeviceDeleted = new EventEmitter(); + @Output() authDeviceToUpdate = new EventEmitter(); + + @ViewChild(MatPaginator) paginator: MatPaginator; + @ViewChild(MatSort) sort: MatSort; + + public dataSource: MatTableDataSource = new MatTableDataSource(); + public displayedColumns = []; + public isLoading = true; + public pageSize = 7; + + constructor() { } + + ngOnInit(): void { + } + + public ngOnChanges() { + this.displayedColumns = [ + 'name', + 'createdAt', + 'actions', + ]; + + this.isLoading = true; + + if (this.authDevices) { + this.dataSource = new MatTableDataSource(this.authDevices); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + + this.isLoading = false; + } + } + + public onDeleteAuthDevice(aId: string) { + const confirmation = confirm('Do you really want to remove this authenticator?'); + + if (confirmation) { + this.authDeviceDeleted.emit(aId); + } + } +} diff --git a/apps/client/src/app/components/auth-device-settings/auth-device-settings.module.ts b/apps/client/src/app/components/auth-device-settings/auth-device-settings.module.ts new file mode 100644 index 000000000..40708d6e6 --- /dev/null +++ b/apps/client/src/app/components/auth-device-settings/auth-device-settings.module.ts @@ -0,0 +1,30 @@ +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatInputModule } from '@angular/material/input'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatSortModule } from '@angular/material/sort'; +import { MatTableModule } from '@angular/material/table'; +import { RouterModule } from '@angular/router'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { AuthDeviceSettingsComponent } from './auth-device-settings.component'; +import { MatPaginatorModule } from '@angular/material/paginator'; + +@NgModule({ + declarations: [AuthDeviceSettingsComponent], + exports: [AuthDeviceSettingsComponent], + imports: [ + CommonModule, + MatButtonModule, + MatInputModule, + MatMenuModule, + MatSortModule, + MatTableModule, + MatPaginatorModule, + NgxSkeletonLoaderModule, + RouterModule + ], + providers: [], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class GfAuthDeviceSettingsModule {} diff --git a/apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.css b/apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.html b/apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.html new file mode 100644 index 000000000..6cbe6e1df --- /dev/null +++ b/apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.html @@ -0,0 +1,25 @@ +
+

Update Device

+

Add Device

+
+
+ + Name + + +
+
+
+ + +
+
diff --git a/apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.ts b/apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.ts new file mode 100644 index 000000000..b2afacf93 --- /dev/null +++ b/apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.ts @@ -0,0 +1,25 @@ +import { Component, Inject, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; + +export interface AuthDeviceDialogParams { + authDevice: AuthDeviceDto, +} + +@Component({ + selector: 'gf-auth-device-dialog', + templateUrl: './auth-device-dialog.component.html', + styleUrls: ['./auth-device-dialog.component.css'] +}) +export class AuthDeviceDialog implements OnInit { + + public constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: AuthDeviceDialogParams + ) { + } + + ngOnInit(): void { + } + +} diff --git a/apps/client/src/app/pages/auth-devices/auth-devices-page-routing.module.ts b/apps/client/src/app/pages/auth-devices/auth-devices-page-routing.module.ts new file mode 100644 index 000000000..3afbe2ba1 --- /dev/null +++ b/apps/client/src/app/pages/auth-devices/auth-devices-page-routing.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; +import { AuthDevicesPageComponent } from '@ghostfolio/client/pages/auth-devices/auth-devices-page.component'; + +const routes: Routes = [ + { path: '', component: AuthDevicesPageComponent, canActivate: [AuthGuard] } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class AuthDevicesPageRoutingModule {} diff --git a/apps/client/src/app/pages/auth-devices/auth-devices-page.component.html b/apps/client/src/app/pages/auth-devices/auth-devices-page.component.html new file mode 100644 index 000000000..f2065da00 --- /dev/null +++ b/apps/client/src/app/pages/auth-devices/auth-devices-page.component.html @@ -0,0 +1,46 @@ +
+
+
+

WebAuthn

+ + +
+
+ +
+
+
+
+ +
+ +
+
+
+ +
+
+
+
+
+
+
diff --git a/apps/client/src/app/pages/auth-devices/auth-devices-page.component.scss b/apps/client/src/app/pages/auth-devices/auth-devices-page.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/apps/client/src/app/pages/auth-devices/auth-devices-page.component.ts b/apps/client/src/app/pages/auth-devices/auth-devices-page.component.ts new file mode 100644 index 000000000..bdcb5b468 --- /dev/null +++ b/apps/client/src/app/pages/auth-devices/auth-devices-page.component.ts @@ -0,0 +1,115 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { startAssertion, startAttestation } from '@simplewebauthn/browser'; +import { filter, switchMap, takeUntil } from 'rxjs/operators'; +import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; +import { HttpClient } from '@angular/common/http'; +import { MatDialog } from '@angular/material/dialog'; +import { ReplaySubject, Subject } from 'rxjs'; +import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; +import { DataService } from '@ghostfolio/client/services/data.service'; +import { AuthDeviceDialog } from '@ghostfolio/client/pages/auth-devices/auth-device-dialog/auth-device-dialog.component'; +import { isNonNull } from '@ghostfolio/client/util/rxjs.util'; + +@Component({ + selector: 'gf-auth-devices-page', + templateUrl: './auth-devices-page.component.html', + styleUrls: ['./auth-devices-page.component.scss'] +}) +export class AuthDevicesPageComponent implements OnDestroy, OnInit { + + public authDevices$: ReplaySubject = new ReplaySubject(1); + + private unsubscribeSubject = new Subject(); + + + constructor( + private dataService: DataService, + private tokenStorageService: TokenStorageService, + private http: HttpClient, + private dialog: MatDialog + ) { + this.fetchAuthDevices(); + } + + public ngOnInit() { + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + public startWebAuthn() { + this.http.get(`/api/auth/webauthn/generate-attestation-options`, {}) + .pipe( + switchMap(attOps => { + return startAttestation(attOps); + }), + switchMap(attResp => { + const dialogRef = this.dialog.open(AuthDeviceDialog, { + data: { + authDevice: {} + } + }); + return dialogRef.afterClosed().pipe(switchMap(data => { + const reqBody = { + ...attResp, + deviceName: data.authDevice.name + }; + return this.http.post(`/api/auth/webauthn/verify-attestation`, reqBody); + })); + }) + ) + .subscribe(() => { + this.fetchAuthDevices(); + }); + } + + public verifyWebAuthn() { + this.http.get(`/api/auth/webauthn/generate-assertion-options`, {}) + .pipe( + switchMap(startAssertion), + switchMap(assertionResponse => this.http.post(`/api/auth/webauthn/verify-assertion`, assertionResponse)) + ) + .subscribe(res => { + if (res?.verified) alert('success'); + else alert('fail'); + }); + } + + public deleteAuthDevice(aId: string) { + this.dataService.deleteAuthDevice(aId).subscribe({ + next: () => { + this.fetchAuthDevices(); + } + }); + } + + public updateAuthDevice(aAuthDevice: AuthDeviceDto) { + const dialogRef = this.dialog.open(AuthDeviceDialog, { + data: { + authDevice: aAuthDevice + } + }); + + dialogRef.afterClosed() + .pipe( + filter(isNonNull), + switchMap(data => this.dataService.updateAuthDevice(data.authDevice)) + ) + .subscribe({ + next: () => { + this.fetchAuthDevices(); + } + }); + } + + private fetchAuthDevices() { + this.dataService + .fetchAuthDevices() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(authDevices => { + this.authDevices$.next(authDevices); + }); + } +} diff --git a/apps/client/src/app/pages/auth-devices/auth-devices-page.module.ts b/apps/client/src/app/pages/auth-devices/auth-devices-page.module.ts new file mode 100644 index 000000000..a6ff7f7f5 --- /dev/null +++ b/apps/client/src/app/pages/auth-devices/auth-devices-page.module.ts @@ -0,0 +1,37 @@ +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AuthDevicesPageRoutingModule } from '@ghostfolio/client/pages/auth-devices/auth-devices-page-routing.module'; +import { AuthDevicesPageComponent } from '@ghostfolio/client/pages/auth-devices/auth-devices-page.component'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { GfAuthDeviceSettingsModule } from '@ghostfolio/client/components/auth-device-settings/auth-device-settings.module'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { AuthDeviceDialog } from '@ghostfolio/client/pages/auth-devices/auth-device-dialog/auth-device-dialog.component'; + + + +@NgModule({ + declarations: [ + AuthDevicesPageComponent, + AuthDeviceDialog, + ], + imports: [ + CommonModule, + AuthDevicesPageRoutingModule, + FormsModule, + GfAuthDeviceSettingsModule, + MatCardModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatDialogModule, + ReactiveFormsModule, + MatButtonModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class AuthDevicesPageModule { } diff --git a/apps/client/src/app/util/rxjs.util.ts b/apps/client/src/app/util/rxjs.util.ts new file mode 100644 index 000000000..09fe1cac6 --- /dev/null +++ b/apps/client/src/app/util/rxjs.util.ts @@ -0,0 +1,3 @@ +export function isNonNull(value: T): value is NonNullable { + return value != null; +} diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index dc14cbb5a..bbe0806a7 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -11,12 +11,14 @@ export const permissions = { createOrder: 'createOrder', createUserAccount: 'createUserAccount', deleteAccount: 'deleteAcccount', + deleteAuthDevice: 'deleteAuthDevice', deleteOrder: 'deleteOrder', deleteUser: 'deleteUser', enableSocialLogin: 'enableSocialLogin', enableSubscription: 'enableSubscription', readForeignPortfolio: 'readForeignPortfolio', updateAccount: 'updateAccount', + updateAuthDevice: 'updateAuthDevice', updateOrder: 'updateOrder', updateUserSettings: 'updateUserSettings' }; @@ -36,10 +38,12 @@ export function getPermissions(aRole: Role): string[] { permissions.createAccount, permissions.createOrder, permissions.deleteAccount, + permissions.deleteAuthDevice, permissions.deleteOrder, permissions.deleteUser, permissions.readForeignPortfolio, permissions.updateAccount, + permissions.updateAuthDevice, permissions.updateOrder, permissions.updateUserSettings ]; @@ -52,8 +56,10 @@ export function getPermissions(aRole: Role): string[] { permissions.createAccount, permissions.createOrder, permissions.deleteAccount, + permissions.deleteAuthDevice, permissions.deleteOrder, permissions.updateAccount, + permissions.updateAuthDevice, permissions.updateOrder, permissions.updateUserSettings ]; diff --git a/package.json b/package.json index 50615c842..b9b2c4699 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,9 @@ "@nestjs/serve-static": "2.1.4", "@nrwl/angular": "12.0.0", "@prisma/client": "2.24.1", + "@simplewebauthn/browser": "3.0.0", + "@simplewebauthn/server": "3.0.0", + "@simplewebauthn/typescript-types": "3.0.0", "@types/lodash": "4.14.168", "alphavantage": "2.2.0", "angular-material-css-vars": "1.1.2", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7cf7c2fa6..13162662c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,6 +47,20 @@ model Analytics { userId String @id } +model AuthDevice { + createdAt DateTime @default(now()) + credentialId Bytes + credentialPublicKey Bytes + counter Int + name String + id String @default(uuid()) + updatedAt DateTime @updatedAt + User User @relation(fields: [userId], references: [id]) + userId String + + @@id([id, userId]) +} + model MarketData { createdAt DateTime @default(now()) date DateTime @@ -126,21 +140,23 @@ model Subscription { } model User { - Access Access[] @relation("accessGet") - AccessGive Access[] @relation(name: "accessGive") - accessToken String? - Account Account[] - alias String? - Analytics Analytics? - createdAt DateTime @default(now()) - id String @id @default(uuid()) - Order Order[] - provider Provider? - role Role @default(USER) - Settings Settings? - Subscription Subscription[] - thirdPartyId String? - updatedAt DateTime @updatedAt + Access Access[] @relation("accessGet") + AccessGive Access[] @relation(name: "accessGive") + accessToken String? + Account Account[] + alias String? + Analytics Analytics? + createdAt DateTime @default(now()) + AuthDevice AuthDevice[] + id String @id @default(uuid()) + Order Order[] + provider Provider? + role Role @default(USER) + Settings Settings? + Subscription Subscription[] + thirdPartyId String? + updatedAt DateTime @updatedAt + authChallenge String? } enum AccountType {