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