mirror of https://github.com/ghostfolio/ghostfolio
				
				
			
							committed by
							
								
								Thomas
							
						
					
				
				 27 changed files with 926 additions and 19 deletions
			
			
		@ -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 | 
				
			|||
    })); | 
				
			|||
  } | 
				
			|||
} | 
				
			|||
@ -0,0 +1,5 @@ | 
				
			|||
export interface AuthDeviceDto { | 
				
			|||
  createdAt: string; | 
				
			|||
  id: string; | 
				
			|||
  name: string; | 
				
			|||
} | 
				
			|||
@ -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 {} | 
				
			|||
@ -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 | 
				
			|||
    }); | 
				
			|||
  } | 
				
			|||
} | 
				
			|||
@ -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 }; | 
				
			|||
  } | 
				
			|||
} | 
				
			|||
@ -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> | 
				
			|||
@ -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); | 
				
			|||
    } | 
				
			|||
  } | 
				
			|||
} | 
				
			|||
@ -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,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> | 
				
			|||
@ -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 { | 
				
			|||
  } | 
				
			|||
 | 
				
			|||
} | 
				
			|||
@ -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 {} | 
				
			|||
@ -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,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); | 
				
			|||
      }); | 
				
			|||
  } | 
				
			|||
} | 
				
			|||
@ -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 { } | 
				
			|||
@ -0,0 +1,3 @@ | 
				
			|||
export function isNonNull<T>(value: T): value is NonNullable<T> { | 
				
			|||
  return value != null; | 
				
			|||
} | 
				
			|||
					Loading…
					
					
				
		Reference in new issue