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