Browse Source

DRAFT: Add webauthn

pull/82/head
Matthias Frey 4 years ago
committed by Thomas
parent
commit
247f4c7d5d
  1. 2
      apps/api/src/app/app.module.ts
  2. 107
      apps/api/src/app/auth-device/auth-device.controller.ts
  3. 5
      apps/api/src/app/auth-device/auth-device.dto.ts
  4. 22
      apps/api/src/app/auth-device/auth-device.module.ts
  5. 70
      apps/api/src/app/auth-device/auth-device.service.ts
  6. 31
      apps/api/src/app/auth/auth.controller.ts
  7. 6
      apps/api/src/app/auth/auth.module.ts
  8. 221
      apps/api/src/app/auth/web-auth.service.ts
  9. 6
      apps/api/src/services/configuration.service.ts
  10. 2
      apps/api/src/services/interfaces/environment.interface.ts
  11. 7
      apps/client/src/app/app-routing.module.ts
  12. 0
      apps/client/src/app/components/auth-device-settings/auth-device-settings.component.css
  13. 58
      apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html
  14. 58
      apps/client/src/app/components/auth-device-settings/auth-device-settings.component.ts
  15. 30
      apps/client/src/app/components/auth-device-settings/auth-device-settings.module.ts
  16. 0
      apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.css
  17. 25
      apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.html
  18. 25
      apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.ts
  19. 14
      apps/client/src/app/pages/auth-devices/auth-devices-page-routing.module.ts
  20. 46
      apps/client/src/app/pages/auth-devices/auth-devices-page.component.html
  21. 0
      apps/client/src/app/pages/auth-devices/auth-devices-page.component.scss
  22. 115
      apps/client/src/app/pages/auth-devices/auth-devices-page.component.ts
  23. 37
      apps/client/src/app/pages/auth-devices/auth-devices-page.module.ts
  24. 3
      apps/client/src/app/util/rxjs.util.ts
  25. 6
      libs/common/src/lib/permissions.ts
  26. 3
      package.json
  27. 46
      prisma/schema.prisma

2
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(),

107
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<AuthDeviceDto> {
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<AuthDeviceDto[]> {
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
}));
}
}

5
apps/api/src/app/auth-device/auth-device.dto.ts

@ -0,0 +1,5 @@
export interface AuthDeviceDto {
createdAt: string;
id: string;
name: string;
}

22
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 {}

70
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<AuthDevice | null> {
return this.prisma.authDevice.findUnique({
where
});
}
public async authDevices(params: {
skip?: number;
take?: number;
cursor?: Prisma.AuthDeviceWhereUniqueInput;
where?: Prisma.AuthDeviceWhereInput;
orderBy?: Prisma.AuthDeviceOrderByInput;
}): Promise<AuthDevice[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prisma.authDevice.findMany({
skip,
take,
cursor,
where,
orderBy
});
}
public async createAuthDevice(
data: Prisma.AuthDeviceCreateInput
): Promise<AuthDevice> {
return this.prisma.authDevice.create({
data
});
}
public async updateAuthDevice(
params: {
where: Prisma.AuthDeviceWhereUniqueInput;
data: Prisma.AuthDeviceUpdateInput;
},
): Promise<AuthDevice> {
const { data, where } = params;
return this.prisma.authDevice.update({
data,
where
});
}
public async deleteAuthDevice(
where: Prisma.AuthDeviceWhereUniqueInput,
): Promise<AuthDevice> {
return this.prisma.authDevice.delete({
where
});
}
}

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

6
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 {}

221
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 };
}
}

6
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' }),
});
}

2
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;
}

7
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: () =>

0
apps/client/src/app/components/auth-device-settings/auth-device-settings.component.css

58
apps/client/src/app/components/auth-device-settings/auth-device-settings.component.html

@ -0,0 +1,58 @@
<table
class="w-100"
matSort
matSortActive="shareCurrent"
matSortDirection="desc"
mat-table
[dataSource]="dataSource"
>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header i18n>Name</th>
<td mat-cell *matCellDef="let element">{{element.name}}</td>
</ng-container>
<ng-container matColumnDef="createdAt">
<th mat-header-cell *matHeaderCellDef mat-sort-header i18n>Created at</th>
<td mat-cell *matCellDef="let element">{{element.createdAt | date}}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-0 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-0 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="accountMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="authDeviceToUpdate.emit(element)">
Edit
</button>
<button i18n mat-menu-item (click)="onDeleteAuthDevice(element.id)">
Delete
</button>
</mat-menu>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
*matRowDef="let row; columns: displayedColumns"
mat-row
></tr>
</table>
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
<mat-paginator class="d-none" [pageSize]="pageSize"></mat-paginator>

58
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<string>();
@Output() authDeviceToUpdate = new EventEmitter<AuthDeviceDto>();
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<AuthDeviceDto> = 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);
}
}
}

30
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 {}

0
apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.css

25
apps/client/src/app/pages/auth-devices/auth-device-dialog/auth-device-dialog.component.html

@ -0,0 +1,25 @@
<form #addAuthDeviceForm="ngForm" class="d-flex flex-column h-100">
<h1 *ngIf="data.authDevice.id" mat-dialog-title i18n>Update Device</h1>
<h1 *ngIf="!data.authDevice.id" mat-dialog-title i18n>Add Device</h1>
<div class="flex-grow-1" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label>
<input matInput name="name" required [(ngModel)]="data.authDevice.name" />
</mat-form-field>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button type='button' i18n mat-button (click)="dialogRef.close()">Cancel</button>
<button
type='submit'
color="primary"
i18n
mat-flat-button
[disabled]="!addAuthDeviceForm.form.valid"
[mat-dialog-close]="data"
>
Save
</button>
</div>
</form>

25
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<AuthDeviceDialog>,
@Inject(MAT_DIALOG_DATA) public data: AuthDeviceDialogParams
) {
}
ngOnInit(): void {
}
}

14
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 {}

46
apps/client/src/app/pages/auth-devices/auth-devices-page.component.html

@ -0,0 +1,46 @@
<div class='container'>
<div class='row'>
<div class='col'>
<h3 class='mb-3 text-center' i18n>WebAuthn</h3>
<mat-card class='mb-3'>
<mat-card-content>
<div class='row mb-3'>
<div class='col'>
<gf-auth-device-settings [authDevices]='authDevices$ | async'
(authDeviceDeleted)='deleteAuthDevice($event)'
(authDeviceToUpdate)='updateAuthDevice($event)'
></gf-auth-device-settings>
</div>
</div>
<div class='row mb-3'>
<div class='col'>
<button
class='d-inline-block'
color='primary'
i18n
mat-flat-button
(click)='startWebAuthn()'
>
Add this device
</button>
</div>
</div>
<div class='row'>
<div class='col'>
<button
class='d-inline-block'
color='primary'
i18n
mat-flat-button
(click)='verifyWebAuthn()'
>
DEBUG: verify WebAuthn
</button>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

0
apps/client/src/app/pages/auth-devices/auth-devices-page.component.scss

115
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<AuthDeviceDto[]> = new ReplaySubject(1);
private unsubscribeSubject = new Subject<void>();
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<any>(`/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<any>(`/api/auth/webauthn/verify-attestation`, reqBody);
}));
})
)
.subscribe(() => {
this.fetchAuthDevices();
});
}
public verifyWebAuthn() {
this.http.get<any>(`/api/auth/webauthn/generate-assertion-options`, {})
.pipe(
switchMap(startAssertion),
switchMap(assertionResponse => this.http.post<any>(`/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);
});
}
}

37
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 { }

3
apps/client/src/app/util/rxjs.util.ts

@ -0,0 +1,3 @@
export function isNonNull<T>(value: T): value is NonNullable<T> {
return value != null;
}

6
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
];

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

46
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 {

Loading…
Cancel
Save